Spaces:
Running
Running
Upload 13 files
Browse files- README.md +64 -8
- app.py +656 -0
- auth.py +191 -0
- detector.py +689 -0
- docx_report.py +360 -0
- favicon.png +0 -0
- humanizer.py +592 -0
- logo.jpg +0 -0
- logo.png +0 -0
- packages.txt +9 -0
- requirements.txt +7 -0
- smart.py +272 -0
README.md
CHANGED
|
@@ -1,15 +1,71 @@
|
|
| 1 |
---
|
| 2 |
-
title: AI Writing Suite
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
-
pinned:
|
| 11 |
license: apache-2.0
|
| 12 |
-
short_description:
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: CS AI Writing Suite
|
| 3 |
+
emoji: ✨
|
| 4 |
+
colorFrom: indigo
|
| 5 |
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
| 8 |
+
python_version: "3.10"
|
| 9 |
app_file: app.py
|
| 10 |
+
pinned: true
|
| 11 |
license: apache-2.0
|
| 12 |
+
short_description: Detect AI writing and humanize it, Word layout kept
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# CS AI Writing Suite — by CS Software Solutions
|
| 16 |
+
|
| 17 |
+
One Space, three tools in a navigation bar:
|
| 18 |
+
|
| 19 |
+
| Tab | What it does |
|
| 20 |
+
|---|---|
|
| 21 |
+
| 🚀 **Smart Humanize** | Detector + humanizer together: scores every sentence with the AI detector, rewrites **only the flagged sentences**, re-checks, and repeats until the target AI % or the pass limit is reached. |
|
| 22 |
+
| 🔍 **AI Detector** | Highlighted AI-likelihood view (cyan = high, purple = moderate) + PDF report. |
|
| 23 |
+
| ✨ **Humanizer** | Full paraphrase of every eligible paragraph (Parrot T5), with the detector score before/after. |
|
| 24 |
+
| 👤 **Account** | Sign in / sign up / profile. |
|
| 25 |
+
| 🛡️ **Admin** | Visible only to emails listed in `ADMIN_EMAILS`. |
|
| 26 |
+
|
| 27 |
+
Shortcut buttons link the tools: *Humanize this text →* (Detector → Smart Humanize) and
|
| 28 |
+
*Send result to AI Detector →* (Humanizer → Detector).
|
| 29 |
+
|
| 30 |
+
## Access rules
|
| 31 |
+
|
| 32 |
+
- **Paste text** — open to everyone, no sign-in (limit `MAX_PASTE_WORDS`, default 6000).
|
| 33 |
+
- **Document upload** (.docx / .pdf / .txt) — requires a free account. Upload tabs show a lock card
|
| 34 |
+
until the user signs in, and the server also rejects uploads from signed-out sessions.
|
| 35 |
+
|
| 36 |
+
## Setup (Settings → Variables and secrets)
|
| 37 |
+
|
| 38 |
+
| Name | Type | Purpose |
|
| 39 |
+
|---|---|---|
|
| 40 |
+
| `HF_TOKEN` | secret | Read token of an account that accepted the terms of the gated `coai/roberta-ai-detector-v2` model |
|
| 41 |
+
| `ADMIN_EMAILS` | secret | Comma-separated admin emails |
|
| 42 |
+
| `APP_NAME` | variable (optional) | Header title (default "CS AI Writing Suite") |
|
| 43 |
+
| `REPORT_BRAND` | variable (optional) | Header text on PDF report pages |
|
| 44 |
+
| `MAX_PASTE_WORDS` | variable (optional) | Paste limit |
|
| 45 |
+
| `DETECTOR_MODEL`, `PARROT_MODEL` | variable (optional) | Swap models |
|
| 46 |
+
|
| 47 |
+
Hardware: **ZeroGPU**. Enable **Persistent storage** so the user database (`/data/users.db`)
|
| 48 |
+
survives restarts.
|
| 49 |
+
|
| 50 |
+
## Files
|
| 51 |
+
|
| 52 |
+
- `app.py` — UI, navigation bar, sign-in gating, handlers
|
| 53 |
+
- `smart.py` — detect → rewrite flagged sentences → re-check loop (in-place Word editing)
|
| 54 |
+
- `detector.py` — AI detector + PDF/HTML report
|
| 55 |
+
- `docx_report.py` — Word highlighting + LibreOffice conversion
|
| 56 |
+
- `humanizer.py` — Parrot paraphraser
|
| 57 |
+
- `auth.py` — accounts and admin
|
| 58 |
+
- `packages.txt` — LibreOffice + fonts (Word → PDF reports)
|
| 59 |
+
- `logo.png`, `favicon.png`
|
| 60 |
+
|
| 61 |
+
## Smart Humanize details
|
| 62 |
+
|
| 63 |
+
- Sentences with P(AI) ≥ the moderate threshold (default 0.50) are rewritten; each later pass uses
|
| 64 |
+
slightly bolder variation.
|
| 65 |
+
- A rewrite is rejected (original kept) if it drops numbers or changes length drastically; dropped
|
| 66 |
+
citations like `[12]` or `(Kumar et al., 2021)` are re-attached.
|
| 67 |
+
- In Word files only the flagged sentence's text is replaced; the rest of the paragraph keeps its
|
| 68 |
+
formatting. Citation fields, hyperlinks and superscript/subscript runs are never edited — the prose
|
| 69 |
+
around them is rewritten instead.
|
| 70 |
+
|
| 71 |
+
Scores are indicators from an open-source detector and will differ from Turnitin, GPTZero, etc.
|
app.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CS AI Writing Suite — one Space, three tools behind a navigation bar:
|
| 3 |
+
|
| 4 |
+
🚀 Smart Humanize detector finds AI-flagged sentences → humanizer rewrites only those → re-check (loop)
|
| 5 |
+
🔍 AI Detector highlighted AI-likelihood PDF report (RoBERTa detector)
|
| 6 |
+
✨ Humanizer Parrot-T5 paraphraser (whole text / whole document)
|
| 7 |
+
|
| 8 |
+
Access rule: pasting text is open to everyone; uploading a document requires sign-in.
|
| 9 |
+
"""
|
| 10 |
+
import html
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
import gradio as gr
|
| 15 |
+
|
| 16 |
+
import humanizer as H # loads the paraphraser (kept on CPU until a GPU call)
|
| 17 |
+
import detector as D # loads the detector (+ starts LibreOffice fetch)
|
| 18 |
+
import smart as S
|
| 19 |
+
import auth as A
|
| 20 |
+
from humanizer import BRAND, RAINBOW, LOGO_SRC, FAVICON_SRC, FAVICON_PATH, MODES, OUT_DIR
|
| 21 |
+
|
| 22 |
+
APP_NAME = os.getenv("APP_NAME", "CS AI Writing Suite")
|
| 23 |
+
SITE_URL = "https://cssoftwaresolutions.in"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# =====================================================================
|
| 27 |
+
# Shared UI helpers
|
| 28 |
+
# =====================================================================
|
| 29 |
+
def _stats_gauge(title, pct, caption, good_low=True):
|
| 30 |
+
if pct is None:
|
| 31 |
+
return H._gauge(title, None, caption)
|
| 32 |
+
return H._gauge(title, pct, caption, invert=not good_low)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def suite_panel(ai_before, ai_after, original, revised):
|
| 36 |
+
sim = H.similarity_to_source(original, revised)
|
| 37 |
+
changed = H.words_changed(original, revised)
|
| 38 |
+
return f"""
|
| 39 |
+
<div class="cs-scores">
|
| 40 |
+
{_stats_gauge("AI score · before", ai_before, "RoBERTa detector on your original")}
|
| 41 |
+
{_stats_gauge("AI score · after", ai_after, "RoBERTa detector on the rewritten text")}
|
| 42 |
+
{H._gauge("Similarity to source", sim, "Word n-gram overlap with your original")}
|
| 43 |
+
{H._gauge("Vocabulary changed", changed, "Share of new wording", fixed_color=BRAND["navy"])}
|
| 44 |
+
</div>
|
| 45 |
+
<div class="cs-disclaimer">
|
| 46 |
+
<b>Indicators only.</b> The AI score comes from an open-source detector and will differ from Turnitin,
|
| 47 |
+
GPTZero or other tools. Similarity is measured against your own text, not internet or journal databases.
|
| 48 |
+
Always review the output for meaning, technical terms and citations before use.
|
| 49 |
+
</div>"""
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
EMPTY = """<div class="cs-scores cs-empty"><div>Scores appear here after processing.</div></div>"""
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _locked(what):
|
| 56 |
+
return (f'<div class="cs-lock"><div class="cs-lock-ico">🔒</div>'
|
| 57 |
+
f'<div class="cs-lock-t">Sign in to upload {what}</div>'
|
| 58 |
+
f'<div class="cs-lock-s">Document upload needs a free account. '
|
| 59 |
+
f'Pasting text works without signing in — use the <b>Paste text</b> tab.</div></div>')
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _userbar(user):
|
| 63 |
+
if not user:
|
| 64 |
+
return '<div class="cs-userbar"><span>👤 Guest — paste text freely · sign in to upload documents</span></div>'
|
| 65 |
+
return (f'<div class="cs-userbar"><span>👋 <b>{html.escape(user["name"])}</b> '
|
| 66 |
+
f'<small>({html.escape(user["email"])})</small></span></div>')
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _profile(user):
|
| 70 |
+
if not user:
|
| 71 |
+
return ""
|
| 72 |
+
with A._db() as con:
|
| 73 |
+
row = con.execute("SELECT created, last_login, runs FROM users WHERE email=?", (user["email"],)).fetchone()
|
| 74 |
+
created, last, runs = row or ("", "", 0)
|
| 75 |
+
return (f'<div class="cs-auth-title">My account</div>'
|
| 76 |
+
f'<div class="cs-prof"><div><span>Name</span><b>{html.escape(user["name"])}</b></div>'
|
| 77 |
+
f'<div><span>Email</span><b>{html.escape(user["email"])}</b></div>'
|
| 78 |
+
f'<div><span>Member since</span><b>{(created or "")[:10]}</b></div>'
|
| 79 |
+
f'<div><span>Document runs</span><b>{runs or 0}</b></div></div>')
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# =====================================================================
|
| 83 |
+
# Handlers — status panel streaming via H._run_live
|
| 84 |
+
# =====================================================================
|
| 85 |
+
def _stream(core, args, n, idx, user=None, need_login=False):
|
| 86 |
+
if need_login:
|
| 87 |
+
A._require(user)
|
| 88 |
+
A._count_run(user)
|
| 89 |
+
for frame in H._run_live(core, args, n, idx):
|
| 90 |
+
yield frame
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ---------------- Smart Humanize ----------------
|
| 94 |
+
def _smart_result(res, hi, mod, text_mode):
|
| 95 |
+
b, a = res["before"], res["after"]
|
| 96 |
+
log = "<br>".join(html.escape(x) for x in res["log"])
|
| 97 |
+
msg = (f"✅ **Done** — AI score {b['pct']}% → {a['pct']}%.\n\n{log}"
|
| 98 |
+
+ (f"\n\n{res['note']}" if res["note"] else ""))
|
| 99 |
+
preview = D.build_html(res["blocks"], a, hi, mod)
|
| 100 |
+
panel = suite_panel(b["pct"], a["pct"], res["orig"], res["new"])
|
| 101 |
+
out = [H.status_done(msg), preview, panel, res["path"], H.download_button(res["path"])]
|
| 102 |
+
if text_mode:
|
| 103 |
+
out.insert(1, res["text"])
|
| 104 |
+
return tuple(out)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _smart_text_core(text, target, passes, temp, mode, hi, mod, rep):
|
| 108 |
+
text = (text or "").strip()
|
| 109 |
+
if not text:
|
| 110 |
+
raise gr.Error("Please paste some text.")
|
| 111 |
+
if len(text.split()) > H.MAX_PASTE_WORDS:
|
| 112 |
+
raise gr.Error(f"Please keep pasted text under {H.MAX_PASTE_WORDS:,} words, or sign in and upload a .docx.")
|
| 113 |
+
res = S.smart_text(text, target, passes, temp, mode, hi, mod, rep)
|
| 114 |
+
return _smart_result(res, hi, mod, True)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _smart_docx_core(path, target, passes, temp, mode, hi, mod, rep):
|
| 118 |
+
if not path or not str(path).lower().endswith(".docx"):
|
| 119 |
+
raise gr.Error("Please upload a Word (.docx) file.")
|
| 120 |
+
res = S.smart_docx(path, target, passes, temp, mode, hi, mod, rep)
|
| 121 |
+
return _smart_result(res, hi, mod, False)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def smart_text(text, target, passes, temp, mode, hi, mod, user):
|
| 125 |
+
yield from _stream(_smart_text_core, (text, target, passes, temp, mode, hi, mod), 6, 0)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def smart_docx(path, target, passes, temp, mode, hi, mod, user):
|
| 129 |
+
yield from _stream(_smart_docx_core, (path, target, passes, temp, mode, hi, mod), 5, 0, user, True)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ---------------- Detector ----------------
|
| 133 |
+
def _det_core(file, text, hi, mod, rep):
|
| 134 |
+
if mod >= hi:
|
| 135 |
+
raise gr.Error("Moderate threshold must be below the high threshold.")
|
| 136 |
+
page, pdf = D.run(file, text, hi, mod, rep)
|
| 137 |
+
return H.status_done("✅ **Report ready** — download the PDF below."), page, pdf
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def detect_text(text, hi, mod, user):
|
| 141 |
+
if not (text or "").strip():
|
| 142 |
+
raise gr.Error("Please paste some text.")
|
| 143 |
+
if len(text.split()) > H.MAX_PASTE_WORDS:
|
| 144 |
+
raise gr.Error(f"Please keep pasted text under {H.MAX_PASTE_WORDS:,} words, or sign in and upload a file.")
|
| 145 |
+
yield from _stream(_det_core, (None, text, hi, mod), 3, 0)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def detect_file(file, hi, mod, user):
|
| 149 |
+
if not file:
|
| 150 |
+
A._require(user)
|
| 151 |
+
raise gr.Error("Please upload a .docx, .pdf or .txt file.")
|
| 152 |
+
yield from _stream(_det_core, (file, None, hi, mod), 3, 0, user, True)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ---------------- Humanizer ----------------
|
| 156 |
+
def _hum_text_core(text, temp, min_words, mode, rep):
|
| 157 |
+
out_text, path, dl, summary, _ = H._text_core(text, temp, min_words, mode, rep)
|
| 158 |
+
rep(0.97, "Checking AI score before/after")
|
| 159 |
+
panel = suite_panel(S.detector_pct_text(text), S.detector_pct_text(out_text), text, out_text)
|
| 160 |
+
return out_text, path, dl, H.status_done(summary), panel
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _hum_docx_core(path, temp, min_words, mode, rep):
|
| 164 |
+
out, dl, summary, _, preview = H._docx_core(path, temp, min_words, mode, rep)
|
| 165 |
+
rep(0.97, "Checking AI score before/after")
|
| 166 |
+
from docx import Document
|
| 167 |
+
orig = "\n".join(p.text for p in Document(path).paragraphs)
|
| 168 |
+
new = "\n".join(p.text for p in Document(out).paragraphs)
|
| 169 |
+
panel = suite_panel(S.detector_pct_docx(path), S.detector_pct_docx(out), orig, new)
|
| 170 |
+
return out, dl, H.status_done(summary), panel, preview
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def humanize_text(text, temp, min_words, mode, user):
|
| 174 |
+
if not (text or "").strip():
|
| 175 |
+
raise gr.Error("Please paste some text to paraphrase.")
|
| 176 |
+
yield from _stream(_hum_text_core, (text, temp, min_words, mode), 5, 3)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def humanize_docx(path, temp, min_words, mode, user):
|
| 180 |
+
yield from _stream(_hum_docx_core, (path, temp, min_words, mode), 5, 2, user, True)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# =====================================================================
|
| 184 |
+
# Auth events
|
| 185 |
+
# =====================================================================
|
| 186 |
+
LOCKS, OPENS = [], [] # filled while building the UI
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _auth_updates(user, msg=""):
|
| 190 |
+
admin = A.is_admin(user)
|
| 191 |
+
signed = bool(user)
|
| 192 |
+
base = [
|
| 193 |
+
user,
|
| 194 |
+
gr.update(visible=not signed), # auth_view
|
| 195 |
+
gr.update(visible=signed), # profile_view
|
| 196 |
+
_profile(user), # profile_html
|
| 197 |
+
_userbar(user), # user_bar
|
| 198 |
+
msg, # auth_msg
|
| 199 |
+
gr.update(visible=admin), # admin_tab
|
| 200 |
+
A.admin_stats(user) if admin else "",
|
| 201 |
+
A.admin_table(user) if admin else None,
|
| 202 |
+
gr.update(visible=not signed), # top sign-in button
|
| 203 |
+
gr.update(visible=signed), # top log-out button
|
| 204 |
+
]
|
| 205 |
+
base += [gr.update(visible=not signed) for _ in LOCKS]
|
| 206 |
+
base += [gr.update(visible=signed) for _ in OPENS]
|
| 207 |
+
return base
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def do_login(email, password):
|
| 211 |
+
user, err = A.check_user(email, password)
|
| 212 |
+
if err:
|
| 213 |
+
return [None, gr.update(), gr.update(), "", _userbar(None), A._msg(err)] + [gr.update()] * (5 + 2 * len(LOCKS))
|
| 214 |
+
return _auth_updates(user, A._msg(f"Welcome back, {html.escape(user['name'])}! Upload is now unlocked.", ok=True))
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def do_signup(name, email, password, confirm):
|
| 218 |
+
user, err = A.create_user(name, email, password, confirm)
|
| 219 |
+
if err:
|
| 220 |
+
return [None, gr.update(), gr.update(), "", _userbar(None), A._msg(err)] + [gr.update()] * (5 + 2 * len(LOCKS))
|
| 221 |
+
return _auth_updates(user, A._msg("Account created — document upload is now unlocked.", ok=True))
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def do_logout():
|
| 225 |
+
return _auth_updates(None, A._msg("You have been logged out.", ok=True))
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# =====================================================================
|
| 229 |
+
# UI
|
| 230 |
+
# =====================================================================
|
| 231 |
+
CSS = f"""
|
| 232 |
+
:root {{ --cs-navy:{BRAND['navy']}; --cs-purple:{BRAND['purple']}; --cs-magenta:{BRAND['magenta']};
|
| 233 |
+
--cs-sky:{BRAND['sky']}; --cs-rainbow:{RAINBOW}; }}
|
| 234 |
+
.gradio-container {{ max-width:1200px !important; margin:0 auto !important;
|
| 235 |
+
font-family:'Poppins', ui-sans-serif, system-ui, sans-serif !important; }}
|
| 236 |
+
body, .gradio-container {{ background:#f6f7fb !important; }}
|
| 237 |
+
footer, #huggingface-space-header, .show-api {{ display:none !important; }}
|
| 238 |
+
|
| 239 |
+
/* header */
|
| 240 |
+
.cs-header {{ display:flex; align-items:center; gap:20px; padding:18px 24px; border-radius:20px 20px 0 0;
|
| 241 |
+
background:#fff; box-shadow:0 8px 30px rgba(7,34,139,.08); }}
|
| 242 |
+
.cs-header img {{ width:74px; height:74px; border-radius:50%; flex:none; box-shadow:0 4px 14px rgba(0,0,0,.12); }}
|
| 243 |
+
.cs-header h1 {{ margin:0; font-size:27px; font-weight:800; color:var(--cs-navy); letter-spacing:-.3px; line-height:1.15; }}
|
| 244 |
+
.cs-header h1 span {{ color:var(--cs-magenta) !important; }}
|
| 245 |
+
.cs-header p {{ margin:4px 0 0; color:#555; font-size:14px; }}
|
| 246 |
+
@media (max-width:640px) {{ .cs-header {{ flex-direction:column; text-align:center; }} .cs-header h1 {{ font-size:22px; }} }}
|
| 247 |
+
|
| 248 |
+
/* account strip under header */
|
| 249 |
+
#cs-strip {{ background:#fff; border-radius:0 0 20px 20px; padding:4px 18px 8px; margin-top:-16px !important;
|
| 250 |
+
box-shadow:0 8px 30px rgba(7,34,139,.08); position:relative; align-items:center; }}
|
| 251 |
+
#cs-strip::before {{ content:""; position:absolute; left:0; right:0; top:0; height:4px; background:var(--cs-rainbow); }}
|
| 252 |
+
.cs-userbar {{ display:flex; align-items:center; padding:8px 2px; color:#444; font-size:14px; }}
|
| 253 |
+
.cs-userbar small {{ color:#888; }}
|
| 254 |
+
.cs-topbtn {{ max-width:150px !important; min-width:110px !important; }}
|
| 255 |
+
|
| 256 |
+
/* navigation bar (top-level tabs) */
|
| 257 |
+
#cs-nav > .tab-wrapper, #cs-nav > div:first-child {{ background:linear-gradient(90deg,var(--cs-navy),var(--cs-purple) 65%,var(--cs-magenta));
|
| 258 |
+
border-radius:14px; padding:6px; margin:14px 0 10px; border:none !important; }}
|
| 259 |
+
#cs-nav > .tab-wrapper button[role="tab"], #cs-nav > div:first-child > button[role="tab"],
|
| 260 |
+
#cs-nav > .tab-wrapper .tab-container > button {{ color:#e9e9ff !important; font-weight:700 !important; font-size:15px !important;
|
| 261 |
+
border:none !important; border-radius:10px !important; padding:10px 18px !important; background:transparent !important; }}
|
| 262 |
+
#cs-nav > .tab-wrapper button[role="tab"].selected, #cs-nav > div:first-child > button[role="tab"].selected,
|
| 263 |
+
#cs-nav > .tab-wrapper .tab-container > button.selected {{ background:#fff !important; color:var(--cs-navy) !important;
|
| 264 |
+
box-shadow:0 3px 10px rgba(0,0,0,.18); }}
|
| 265 |
+
#cs-nav > .tab-wrapper button[role="tab"]:hover {{ background:rgba(255,255,255,.14) !important; }}
|
| 266 |
+
#cs-nav > .tab-wrapper .tab-container::after, #cs-nav > .tab-wrapper .overflow-menu {{ display:none !important; }}
|
| 267 |
+
#cs-nav > .tab-wrapper .tab-container {{ height:auto !important; flex-wrap:wrap; gap:4px; }}
|
| 268 |
+
|
| 269 |
+
.sub-tabs button[role="tab"] {{ font-weight:600 !important; }}
|
| 270 |
+
.sub-tabs button[role="tab"].selected {{ color:var(--cs-navy) !important; border-color:var(--cs-magenta) !important; }}
|
| 271 |
+
|
| 272 |
+
.cs-intro {{ background:#fff; border:1px solid #eceef6; border-radius:14px; padding:12px 16px; color:#444;
|
| 273 |
+
font-size:14px; margin-bottom:8px; }}
|
| 274 |
+
.cs-intro b {{ color:var(--cs-navy); }}
|
| 275 |
+
.cs-flow {{ display:flex; gap:8px; flex-wrap:wrap; margin-top:8px; }}
|
| 276 |
+
.cs-flow span {{ background:#f1f3fb; border-radius:99px; padding:4px 12px; font-size:12.5px; font-weight:600; color:var(--cs-navy); }}
|
| 277 |
+
|
| 278 |
+
.cs-card {{ background:#fff !important; border-radius:18px !important; padding:18px !important;
|
| 279 |
+
box-shadow:0 6px 24px rgba(7,34,139,.06) !important; border:1px solid #eceef6 !important; }}
|
| 280 |
+
button.primary {{ background:linear-gradient(90deg,var(--cs-navy),var(--cs-purple) 60%,var(--cs-magenta)) !important;
|
| 281 |
+
color:#fff !important; border:none !important; font-weight:700 !important; font-size:16px !important;
|
| 282 |
+
border-radius:12px !important; box-shadow:0 6px 18px rgba(71,20,139,.28) !important; }}
|
| 283 |
+
button.primary:hover {{ filter:brightness(1.08); }}
|
| 284 |
+
|
| 285 |
+
.cs-lock {{ text-align:center; padding:34px 20px 18px; }}
|
| 286 |
+
.cs-lock-ico {{ font-size:44px; }}
|
| 287 |
+
.cs-lock-t {{ font-weight:800; font-size:20px; color:var(--cs-navy); margin-top:6px; }}
|
| 288 |
+
.cs-lock-s {{ color:#666; font-size:14px; margin:6px auto 0; max-width:520px; }}
|
| 289 |
+
.cs-lockbtn {{ max-width:260px !important; margin:0 auto 22px !important; }}
|
| 290 |
+
|
| 291 |
+
.cs-scores {{ display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-top:6px; }}
|
| 292 |
+
@media (max-width:820px) {{ .cs-scores {{ grid-template-columns:repeat(2,1fr); }} }}
|
| 293 |
+
.cs-scores.cs-empty {{ display:block; text-align:center; color:#888; padding:22px; border:2px dashed #e3e6f2; border-radius:16px; }}
|
| 294 |
+
.cs-gauge {{ background:#fff; border:1px solid #eceef6; border-radius:16px; padding:16px 10px; text-align:center;
|
| 295 |
+
box-shadow:0 4px 16px rgba(7,34,139,.05); }}
|
| 296 |
+
.cs-ring {{ width:104px; height:104px; margin:0 auto 10px; border-radius:50%;
|
| 297 |
+
background:conic-gradient(var(--c) calc(var(--p)*1%), #edf0f7 0); display:grid; place-items:center; }}
|
| 298 |
+
.cs-ring-inner {{ width:80px; height:80px; border-radius:50%; background:#fff; display:grid; place-items:center; }}
|
| 299 |
+
.cs-ring-inner span {{ font-size:22px; font-weight:800; color:#222; }}
|
| 300 |
+
.cs-gauge-title {{ font-weight:700; color:var(--cs-navy); font-size:14px; }}
|
| 301 |
+
.cs-chip {{ display:inline-block; margin:6px 0; padding:2px 12px; border-radius:99px; font-size:12px; font-weight:700; }}
|
| 302 |
+
.cs-gauge-cap {{ font-size:11.5px; color:#777; line-height:1.35; }}
|
| 303 |
+
.cs-disclaimer {{ margin-top:12px; font-size:12px; color:#666; background:#fff8ec; border-left:4px solid {BRAND['orange']};
|
| 304 |
+
padding:10px 14px; border-radius:10px; }}
|
| 305 |
+
.cs-count {{ text-align:right; font-size:12px; margin-top:-6px; }}
|
| 306 |
+
|
| 307 |
+
.cs-status {{ padding:16px 18px; border-radius:14px; font-size:14.5px; line-height:1.5; }}
|
| 308 |
+
.cs-idle {{ color:#666; }}
|
| 309 |
+
.cs-done {{ background:#f1fbf1; border-left:4px solid {BRAND['green']}; color:#234; }}
|
| 310 |
+
.cs-err {{ background:#fff3f3; border-left:4px solid {BRAND['red']}; color:#8a1c1c; }}
|
| 311 |
+
.cs-run {{ text-align:center; background:#fff; border:1px solid #eceef6; }}
|
| 312 |
+
.cs-spin-logo {{ width:64px; height:64px; margin:4px auto 12px;
|
| 313 |
+
background:url("{FAVICON_SRC}") center/contain no-repeat; animation:cs-spin 1.4s linear infinite; }}
|
| 314 |
+
.cs-st-text {{ font-weight:600; color:var(--cs-navy); margin-bottom:10px; }}
|
| 315 |
+
.cs-bar {{ height:10px; border-radius:99px; background:#edf0f7; overflow:hidden; }}
|
| 316 |
+
.cs-bar > div {{ height:100%; border-radius:99px; background:var(--cs-rainbow); transition:width .5s ease; }}
|
| 317 |
+
.cs-st-meta {{ margin-top:8px; font-size:12px; color:#777; }}
|
| 318 |
+
@keyframes cs-spin {{ to {{ transform:rotate(360deg); }} }}
|
| 319 |
+
.cs-dl {{ display:block; text-align:center; margin-top:10px; padding:12px 16px; border-radius:12px; font-weight:700;
|
| 320 |
+
color:#fff !important; text-decoration:none !important;
|
| 321 |
+
background:linear-gradient(90deg,var(--cs-navy),var(--cs-purple) 60%,var(--cs-magenta)); }}
|
| 322 |
+
.cs-settings {{ background:#f8f9fd !important; border:1px solid #eceef6 !important; border-radius:14px !important;
|
| 323 |
+
padding:12px 14px !important; margin:10px 0 !important; }}
|
| 324 |
+
.cs-settings-title {{ font-weight:700; color:var(--cs-navy); font-size:15px; margin:0 0 4px; }}
|
| 325 |
+
|
| 326 |
+
.cs-stats {{ display:grid; grid-template-columns:repeat(5,1fr); gap:12px; }}
|
| 327 |
+
@media (max-width:820px) {{ .cs-stats {{ grid-template-columns:repeat(2,1fr); }} }}
|
| 328 |
+
.cs-stat {{ background:#fff; border:1px solid #eceef6; border-radius:14px; padding:14px; text-align:center; }}
|
| 329 |
+
.cs-stat .n {{ font-size:26px; font-weight:800; color:var(--cs-navy); }}
|
| 330 |
+
.cs-stat .l {{ font-size:12px; color:#777; margin-top:2px; }}
|
| 331 |
+
.cs-auth {{ max-width:480px; margin:10px auto 0 !important; }}
|
| 332 |
+
.cs-auth-title {{ text-align:center; font-weight:800; font-size:22px; color:var(--cs-navy); margin:4px 0 2px; }}
|
| 333 |
+
.cs-auth-sub {{ text-align:center; color:#666; font-size:13.5px; margin-bottom:10px; }}
|
| 334 |
+
.cs-authmsg {{ padding:10px 14px; border-radius:10px; font-size:13.5px; margin-top:6px; }}
|
| 335 |
+
.cs-ok {{ background:#f1fbf1; color:#1e5b1e; border-left:4px solid {BRAND['green']}; }}
|
| 336 |
+
.cs-bad {{ background:#fff3f3; color:#8a1c1c; border-left:4px solid {BRAND['red']}; }}
|
| 337 |
+
.cs-prof {{ display:grid; gap:8px; margin-top:12px; }}
|
| 338 |
+
.cs-prof div {{ display:flex; justify-content:space-between; border-bottom:1px solid #f0f1f7; padding:6px 2px; }}
|
| 339 |
+
.cs-prof span {{ color:#777; }}
|
| 340 |
+
|
| 341 |
+
.cs-footer {{ text-align:center; margin-top:18px; padding:16px; color:#666; font-size:13px; }}
|
| 342 |
+
.cs-footer .bar {{ height:3px; width:220px; margin:0 auto 12px; border-radius:3px; background:var(--cs-rainbow); }}
|
| 343 |
+
.cs-footer a {{ color:var(--cs-navy); font-weight:700; text-decoration:none; }}
|
| 344 |
+
"""
|
| 345 |
+
|
| 346 |
+
FORCE_LIGHT = """
|
| 347 |
+
() => {
|
| 348 |
+
const url = new URL(window.location);
|
| 349 |
+
if (url.searchParams.get('__theme') !== 'light') {
|
| 350 |
+
url.searchParams.set('__theme', 'light'); window.location.href = url.href; return;
|
| 351 |
+
}
|
| 352 |
+
const hide = () => { const h = document.getElementById('huggingface-space-header');
|
| 353 |
+
if (h) h.style.setProperty('display', 'none', 'important'); };
|
| 354 |
+
hide(); new MutationObserver(hide).observe(document.documentElement, { childList: true, subtree: true });
|
| 355 |
+
const ICON = "__FAVICON__";
|
| 356 |
+
if (ICON) { document.querySelectorAll("link[rel*='icon']").forEach(l => l.remove());
|
| 357 |
+
const link = document.createElement('link'); link.rel = 'icon'; link.type = 'image/png'; link.href = ICON;
|
| 358 |
+
document.head.appendChild(link); }
|
| 359 |
+
}
|
| 360 |
+
""".replace("__FAVICON__", FAVICON_SRC)
|
| 361 |
+
|
| 362 |
+
_logo = f'<img src="{LOGO_SRC}" alt="logo">' if LOGO_SRC else ""
|
| 363 |
+
_title = html.escape(APP_NAME)
|
| 364 |
+
_title = _title.rsplit(" ", 1)[0] + f" <span>{_title.rsplit(' ', 1)[-1]}</span>" if " " in _title else _title
|
| 365 |
+
HEADER = f"""
|
| 366 |
+
<div class="cs-header">{_logo}
|
| 367 |
+
<div><h1>{_title}</h1>
|
| 368 |
+
<p>Detect AI-written passages and humanize them — sentence by sentence, with your Word layout kept intact.</p></div>
|
| 369 |
+
</div>"""
|
| 370 |
+
|
| 371 |
+
FOOTER = f"""
|
| 372 |
+
<div class="cs-footer"><div class="bar"></div>
|
| 373 |
+
© CS Software Solutions · Tri-Crest Press | <a href="{SITE_URL}" target="_blank">cssoftwaresolutions.in</a><br>
|
| 374 |
+
Rewritten output should be checked for accuracy and properly cited before submission.
|
| 375 |
+
</div>"""
|
| 376 |
+
|
| 377 |
+
theme = gr.themes.Soft(primary_hue="indigo", secondary_hue="pink", neutral_hue="slate",
|
| 378 |
+
font=[gr.themes.GoogleFont("Poppins"), "ui-sans-serif", "sans-serif"], radius_size="lg")
|
| 379 |
+
|
| 380 |
+
HID = dict(show_progress="hidden", api_name=False)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def thresholds():
|
| 384 |
+
with gr.Accordion("Detector thresholds", open=False):
|
| 385 |
+
hi = gr.Slider(0.5, 0.99, 0.80, step=0.01, label="High (cyan) ≥")
|
| 386 |
+
mod = gr.Slider(0.3, 0.95, 0.50, step=0.01, label="Moderate (purple) ≥ — sentences above this are rewritten")
|
| 387 |
+
return hi, mod
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def lock_gate(what):
|
| 391 |
+
"""Returns (lock column, open column). Caller builds the upload UI inside the open column."""
|
| 392 |
+
lock = gr.Column(visible=True, elem_classes="cs-card")
|
| 393 |
+
with lock:
|
| 394 |
+
gr.HTML(_locked(what))
|
| 395 |
+
btn = gr.Button("🔐 Sign in / Create free account", variant="primary", elem_classes="cs-lockbtn")
|
| 396 |
+
opened = gr.Column(visible=False)
|
| 397 |
+
LOCKS.append(lock)
|
| 398 |
+
OPENS.append(opened)
|
| 399 |
+
return lock, opened, btn
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
GOTO_ACCOUNT = []
|
| 403 |
+
|
| 404 |
+
with gr.Blocks(title=f"{APP_NAME} · CS Software Solutions", theme=theme, css=CSS, js=FORCE_LIGHT) as demo:
|
| 405 |
+
user_state = gr.State(None)
|
| 406 |
+
gr.HTML(HEADER)
|
| 407 |
+
with gr.Row(elem_id="cs-strip", equal_height=True):
|
| 408 |
+
user_bar = gr.HTML(_userbar(None))
|
| 409 |
+
top_signin = gr.Button("🔐 Sign in", size="sm", elem_classes="cs-topbtn", scale=0)
|
| 410 |
+
top_logout = gr.Button("Log out", size="sm", elem_classes="cs-topbtn", visible=False, scale=0)
|
| 411 |
+
|
| 412 |
+
with gr.Tabs(elem_id="cs-nav", selected="smart") as nav:
|
| 413 |
+
# ============================ SMART HUMANIZE ============================
|
| 414 |
+
with gr.Tab("🚀 Smart Humanize", id="smart"):
|
| 415 |
+
gr.HTML('<div class="cs-intro"><b>Detector + Humanizer together.</b> The AI detector scores every sentence, '
|
| 416 |
+
'only the flagged ones are rewritten, then the text is re-checked — repeated until your target '
|
| 417 |
+
'score or the pass limit is reached. Low-scoring sentences stay exactly as you wrote them.'
|
| 418 |
+
'<div class="cs-flow"><span>1 · Detect</span><span>2 · Rewrite flagged sentences</span>'
|
| 419 |
+
'<span>3 · Re-check</span><span>4 · Repeat</span></div></div>')
|
| 420 |
+
with gr.Group(elem_classes="cs-settings"):
|
| 421 |
+
gr.HTML('<div class="cs-settings-title">⚙️ Settings</div>')
|
| 422 |
+
with gr.Row():
|
| 423 |
+
s_target = gr.Slider(0, 60, value=10, step=1, label="Target AI score (%)",
|
| 424 |
+
info="Stop as soon as the document is at or below this")
|
| 425 |
+
s_passes = gr.Slider(1, 4, value=3, step=1, label="Maximum passes",
|
| 426 |
+
info="Each pass re-checks and rewrites what is still flagged")
|
| 427 |
+
with gr.Row():
|
| 428 |
+
s_temp = gr.Slider(0.3, 1.5, value=0.9, step=0.1, label="Rewrite variation",
|
| 429 |
+
info="Increases a little on each extra pass")
|
| 430 |
+
s_mode = gr.Radio(MODES, value=MODES[0], label="Processing")
|
| 431 |
+
s_hi, s_mod = thresholds()
|
| 432 |
+
with gr.Tabs(elem_classes="sub-tabs"):
|
| 433 |
+
with gr.Tab("✍️ Paste text (no sign-in)"):
|
| 434 |
+
with gr.Row(equal_height=True):
|
| 435 |
+
with gr.Column(elem_classes="cs-card"):
|
| 436 |
+
sp_in = gr.Textbox(label="Your text", lines=14, max_lines=30,
|
| 437 |
+
placeholder="Paste paragraphs here, separated by a blank line (25+ words each).")
|
| 438 |
+
sp_count = gr.HTML(H.count_words(""))
|
| 439 |
+
with gr.Column(elem_classes="cs-card"):
|
| 440 |
+
sp_out = gr.Textbox(label="Humanized text", lines=14, max_lines=30,
|
| 441 |
+
show_copy_button=True, interactive=False)
|
| 442 |
+
sp_btn = gr.Button("🚀 Detect & humanize", variant="primary", size="lg")
|
| 443 |
+
sp_status = gr.HTML(H.status_idle("Paste text and press **Detect & humanize**."))
|
| 444 |
+
sp_scores = gr.HTML(EMPTY)
|
| 445 |
+
with gr.Row():
|
| 446 |
+
sp_file = gr.File(label="Download as .docx", interactive=False)
|
| 447 |
+
sp_dl = gr.HTML()
|
| 448 |
+
with gr.Accordion("Final AI highlight view", open=False):
|
| 449 |
+
sp_prev = gr.HTML()
|
| 450 |
+
with gr.Tab("📄 Upload Word (.docx) 🔒"):
|
| 451 |
+
_, sd_open, sd_lockbtn = lock_gate("Word documents")
|
| 452 |
+
GOTO_ACCOUNT.append(sd_lockbtn)
|
| 453 |
+
with sd_open:
|
| 454 |
+
with gr.Row(equal_height=False):
|
| 455 |
+
with gr.Column(elem_classes="cs-card"):
|
| 456 |
+
sd_in = gr.File(label="Word document (.docx)", file_types=[".docx"], type="filepath")
|
| 457 |
+
sd_btn = gr.Button("🚀 Detect & humanize document", variant="primary", size="lg")
|
| 458 |
+
gr.Markdown("<small>Only flagged sentences are replaced; formatting of everything else, "
|
| 459 |
+
"tables, figures, equations and headers stay untouched. Citation fields, hyperlinks "
|
| 460 |
+
"and super/subscripts inside a sentence are never altered.</small>")
|
| 461 |
+
with gr.Column(elem_classes="cs-card"):
|
| 462 |
+
sd_status = gr.HTML(H.status_idle("Upload a .docx and press **Detect & humanize document**."))
|
| 463 |
+
sd_file = gr.File(label="Download humanized .docx", interactive=False)
|
| 464 |
+
sd_dl = gr.HTML()
|
| 465 |
+
sd_scores = gr.HTML(EMPTY)
|
| 466 |
+
with gr.Accordion("Final AI highlight view", open=False):
|
| 467 |
+
sd_prev = gr.HTML()
|
| 468 |
+
|
| 469 |
+
# ============================ DETECTOR ============================
|
| 470 |
+
with gr.Tab("🔍 AI Detector", id="detect"):
|
| 471 |
+
gr.HTML('<div class="cs-intro"><b>AI Writing Report.</b> Sentence-level highlighting '
|
| 472 |
+
'(cyan = high, purple = moderate) plus a downloadable PDF report. Results are probabilistic indicators.</div>')
|
| 473 |
+
d_hi, d_mod = thresholds()
|
| 474 |
+
with gr.Tabs(elem_classes="sub-tabs"):
|
| 475 |
+
with gr.Tab("✍️ Paste text (no sign-in)"):
|
| 476 |
+
with gr.Row(equal_height=False):
|
| 477 |
+
with gr.Column(scale=1, elem_classes="cs-card"):
|
| 478 |
+
dp_in = gr.Textbox(label="Your text", lines=14, max_lines=30,
|
| 479 |
+
placeholder="Paste paragraphs here, separated by a blank line.")
|
| 480 |
+
dp_btn = gr.Button("🔍 Check for AI", variant="primary", size="lg")
|
| 481 |
+
dp_status = gr.HTML(H.status_idle("Paste text and press **Check for AI**."))
|
| 482 |
+
dp_pdf = gr.File(label="PDF report", interactive=False)
|
| 483 |
+
dp_to_smart = gr.Button("🚀 Humanize this text →")
|
| 484 |
+
with gr.Column(scale=2):
|
| 485 |
+
dp_html = gr.HTML()
|
| 486 |
+
with gr.Tab("📄 Upload document 🔒"):
|
| 487 |
+
_, dd_open, dd_lockbtn = lock_gate("documents for AI detection")
|
| 488 |
+
GOTO_ACCOUNT.append(dd_lockbtn)
|
| 489 |
+
with dd_open:
|
| 490 |
+
with gr.Row(equal_height=False):
|
| 491 |
+
with gr.Column(scale=1, elem_classes="cs-card"):
|
| 492 |
+
dd_in = gr.File(label="Document (.docx, .pdf, .txt)",
|
| 493 |
+
file_types=[".docx", ".pdf", ".txt", ".md"], type="filepath")
|
| 494 |
+
dd_btn = gr.Button("🔍 Generate report", variant="primary", size="lg")
|
| 495 |
+
dd_status = gr.HTML(H.status_idle("Upload a file and press **Generate report**."))
|
| 496 |
+
dd_pdf = gr.File(label="PDF report", interactive=False)
|
| 497 |
+
dd_to_smart = gr.Button("🚀 Humanize this .docx →")
|
| 498 |
+
with gr.Column(scale=2):
|
| 499 |
+
dd_html = gr.HTML()
|
| 500 |
+
|
| 501 |
+
# ============================ HUMANIZER ============================
|
| 502 |
+
with gr.Tab("✨ Humanizer", id="humanize"):
|
| 503 |
+
gr.HTML('<div class="cs-intro"><b>Full paraphrase.</b> Rewrites every eligible paragraph with the Parrot '
|
| 504 |
+
'engine, then shows the detector score before and after. For targeted rewriting of only the '
|
| 505 |
+
'AI-flagged sentences, use <b>Smart Humanize</b>.</div>')
|
| 506 |
+
with gr.Tabs(elem_classes="sub-tabs"):
|
| 507 |
+
with gr.Tab("✍️ Paste text (no sign-in)"):
|
| 508 |
+
with gr.Row(equal_height=True):
|
| 509 |
+
with gr.Column(elem_classes="cs-card"):
|
| 510 |
+
hp_in = gr.Textbox(label="Your text", lines=14, max_lines=30,
|
| 511 |
+
placeholder="Paste your paragraphs here. Separate paragraphs with a blank line.")
|
| 512 |
+
hp_count = gr.HTML(H.count_words(""))
|
| 513 |
+
with gr.Column(elem_classes="cs-card"):
|
| 514 |
+
hp_out = gr.Textbox(label="Paraphrased text", lines=14, max_lines=30,
|
| 515 |
+
show_copy_button=True, interactive=False)
|
| 516 |
+
with gr.Row():
|
| 517 |
+
with gr.Column():
|
| 518 |
+
with gr.Group(elem_classes="cs-settings"):
|
| 519 |
+
gr.HTML('<div class="cs-settings-title">⚙️ Settings</div>')
|
| 520 |
+
hp_temp = gr.Slider(0.3, 1.5, value=0.9, step=0.1, label="Paraphrase variation")
|
| 521 |
+
hp_min = gr.Slider(3, 30, value=6, step=1, label="Minimum words per paragraph")
|
| 522 |
+
hp_mode = gr.Radio(MODES, value=MODES[0], label="Processing")
|
| 523 |
+
hp_btn = gr.Button("✨ Paraphrase text", variant="primary", size="lg")
|
| 524 |
+
with gr.Column():
|
| 525 |
+
hp_status = gr.HTML(H.status_idle("Paste text and press **Paraphrase text**."))
|
| 526 |
+
hp_file = gr.File(label="Download as .docx", interactive=False)
|
| 527 |
+
hp_dl = gr.HTML()
|
| 528 |
+
hp_to_det = gr.Button("🔍 Send result to AI Detector →")
|
| 529 |
+
hp_scores = gr.HTML(EMPTY)
|
| 530 |
+
with gr.Tab("📄 Upload Word (.docx) 🔒"):
|
| 531 |
+
_, hd_open, hd_lockbtn = lock_gate("Word documents")
|
| 532 |
+
GOTO_ACCOUNT.append(hd_lockbtn)
|
| 533 |
+
with hd_open:
|
| 534 |
+
with gr.Row(equal_height=False):
|
| 535 |
+
with gr.Column(elem_classes="cs-card"):
|
| 536 |
+
hd_in = gr.File(label="Word document (.docx)", file_types=[".docx"], type="filepath")
|
| 537 |
+
with gr.Group(elem_classes="cs-settings"):
|
| 538 |
+
gr.HTML('<div class="cs-settings-title">⚙️ Settings</div>')
|
| 539 |
+
hd_temp = gr.Slider(0.3, 1.5, value=0.9, step=0.1, label="Paraphrase variation")
|
| 540 |
+
hd_min = gr.Slider(5, 30, value=8, step=1, label="Minimum words per paragraph")
|
| 541 |
+
hd_mode = gr.Radio(MODES, value=MODES[0], label="Processing")
|
| 542 |
+
hd_btn = gr.Button("✨ Paraphrase document", variant="primary", size="lg")
|
| 543 |
+
with gr.Column(elem_classes="cs-card"):
|
| 544 |
+
hd_status = gr.HTML(H.status_idle("Upload a document and press **Paraphrase document**."))
|
| 545 |
+
hd_file = gr.File(label="Download paraphrased .docx", interactive=False)
|
| 546 |
+
hd_dl = gr.HTML()
|
| 547 |
+
hd_scores = gr.HTML(EMPTY)
|
| 548 |
+
with gr.Accordion("Preview of changes (first 10)", open=False):
|
| 549 |
+
hd_prev = gr.Markdown()
|
| 550 |
+
|
| 551 |
+
# ============================ ACCOUNT ============================
|
| 552 |
+
with gr.Tab("👤 Account", id="account"):
|
| 553 |
+
with gr.Column(visible=True, elem_classes="cs-auth") as auth_view:
|
| 554 |
+
with gr.Column(elem_classes="cs-card"):
|
| 555 |
+
gr.HTML('<div class="cs-auth-title">Welcome</div>'
|
| 556 |
+
'<div class="cs-auth-sub">Sign in or create a free account to upload documents. '
|
| 557 |
+
'Pasting text never needs an account.</div>')
|
| 558 |
+
with gr.Tabs():
|
| 559 |
+
with gr.Tab("🔐 Log in"):
|
| 560 |
+
li_email = gr.Textbox(label="Email", placeholder="you@university.edu")
|
| 561 |
+
li_pw = gr.Textbox(label="Password", type="password")
|
| 562 |
+
li_btn = gr.Button("Log in", variant="primary", size="lg")
|
| 563 |
+
with gr.Tab("📝 Sign up"):
|
| 564 |
+
su_name = gr.Textbox(label="Full name")
|
| 565 |
+
su_email = gr.Textbox(label="Email", placeholder="you@university.edu")
|
| 566 |
+
su_pw = gr.Textbox(label="Password", type="password", info="At least 8 characters")
|
| 567 |
+
su_pw2 = gr.Textbox(label="Confirm password", type="password")
|
| 568 |
+
su_btn = gr.Button("Create account", variant="primary", size="lg")
|
| 569 |
+
with gr.Column(visible=False, elem_classes="cs-auth") as profile_view:
|
| 570 |
+
with gr.Column(elem_classes="cs-card"):
|
| 571 |
+
profile_html = gr.HTML()
|
| 572 |
+
prof_logout = gr.Button("Log out")
|
| 573 |
+
auth_msg = gr.HTML()
|
| 574 |
+
|
| 575 |
+
# ============================ ADMIN ============================
|
| 576 |
+
with gr.Tab("🛡️ Admin", id="admin", visible=False) as admin_tab:
|
| 577 |
+
ad_stats = gr.HTML()
|
| 578 |
+
with gr.Row():
|
| 579 |
+
ad_query = gr.Textbox(label="Search users", placeholder="Name or email", scale=4)
|
| 580 |
+
ad_refresh = gr.Button("🔄 Refresh", scale=1)
|
| 581 |
+
ad_export = gr.Button("⬇️ Export CSV", scale=1)
|
| 582 |
+
ad_table = gr.Dataframe(headers=A.ADMIN_COLS, interactive=False, wrap=True)
|
| 583 |
+
ad_csv = gr.File(label="Users export", interactive=False)
|
| 584 |
+
with gr.Row(equal_height=False):
|
| 585 |
+
with gr.Column(elem_classes="cs-card"):
|
| 586 |
+
gr.HTML('<div class="cs-settings-title">Enable / disable account</div>')
|
| 587 |
+
ad_email = gr.Textbox(label="User email")
|
| 588 |
+
with gr.Row():
|
| 589 |
+
ad_disable = gr.Button("🚫 Disable")
|
| 590 |
+
ad_enable = gr.Button("✅ Enable")
|
| 591 |
+
with gr.Column(elem_classes="cs-card"):
|
| 592 |
+
gr.HTML('<div class="cs-settings-title">Reset password</div>')
|
| 593 |
+
ad_pw_email = gr.Textbox(label="User email")
|
| 594 |
+
ad_new_pw = gr.Textbox(label="New password", type="password")
|
| 595 |
+
ad_reset = gr.Button("🔑 Reset password")
|
| 596 |
+
with gr.Column(elem_classes="cs-card"):
|
| 597 |
+
gr.HTML('<div class="cs-settings-title">Delete account</div>')
|
| 598 |
+
ad_del_email = gr.Textbox(label="User email")
|
| 599 |
+
ad_del_confirm = gr.Textbox(label="Type the email again to confirm")
|
| 600 |
+
ad_delete = gr.Button("🗑️ Delete user", variant="stop")
|
| 601 |
+
ad_msg = gr.HTML()
|
| 602 |
+
|
| 603 |
+
gr.HTML(FOOTER)
|
| 604 |
+
|
| 605 |
+
# ============================ EVENTS ============================
|
| 606 |
+
# navigation shortcuts
|
| 607 |
+
for b in GOTO_ACCOUNT + [top_signin]:
|
| 608 |
+
b.click(lambda: gr.Tabs(selected="account"), None, nav, **HID)
|
| 609 |
+
dp_to_smart.click(lambda t: (gr.Tabs(selected="smart"), t), dp_in, [nav, sp_in], **HID)
|
| 610 |
+
dd_to_smart.click(lambda f: (gr.Tabs(selected="smart"), f if f and str(f).lower().endswith(".docx") else None),
|
| 611 |
+
dd_in, [nav, sd_in], **HID)
|
| 612 |
+
hp_to_det.click(lambda t: (gr.Tabs(selected="detect"), t), hp_out, [nav, dp_in], **HID)
|
| 613 |
+
|
| 614 |
+
# auth
|
| 615 |
+
auth_out = ([user_state, auth_view, profile_view, profile_html, user_bar, auth_msg, admin_tab,
|
| 616 |
+
ad_stats, ad_table, top_signin, top_logout] + LOCKS + OPENS)
|
| 617 |
+
li_btn.click(do_login, [li_email, li_pw], auth_out, **HID)
|
| 618 |
+
li_pw.submit(do_login, [li_email, li_pw], auth_out, **HID)
|
| 619 |
+
su_btn.click(do_signup, [su_name, su_email, su_pw, su_pw2], auth_out, **HID)
|
| 620 |
+
su_pw2.submit(do_signup, [su_name, su_email, su_pw, su_pw2], auth_out, **HID)
|
| 621 |
+
for b in (top_logout, prof_logout):
|
| 622 |
+
b.click(do_logout, None, auth_out, **HID)
|
| 623 |
+
|
| 624 |
+
# admin
|
| 625 |
+
ad_out = [ad_stats, ad_table, ad_msg]
|
| 626 |
+
ad_refresh.click(lambda u, q: A._admin_refresh(u, q, "Refreshed."), [user_state, ad_query], ad_out, **HID)
|
| 627 |
+
ad_query.submit(A.admin_search, [user_state, ad_query], ad_table, **HID)
|
| 628 |
+
ad_disable.click(lambda u, e, q: A.admin_set_active(u, e, q, False), [user_state, ad_email, ad_query], ad_out, **HID)
|
| 629 |
+
ad_enable.click(lambda u, e, q: A.admin_set_active(u, e, q, True), [user_state, ad_email, ad_query], ad_out, **HID)
|
| 630 |
+
ad_reset.click(A.admin_reset_pw, [user_state, ad_pw_email, ad_new_pw, ad_query], ad_out, **HID)
|
| 631 |
+
ad_delete.click(A.admin_delete, [user_state, ad_del_email, ad_del_confirm, ad_query], ad_out, **HID)
|
| 632 |
+
ad_export.click(A.admin_export, [user_state], ad_csv, **HID)
|
| 633 |
+
|
| 634 |
+
# smart humanize
|
| 635 |
+
sp_in.change(H.count_words, sp_in, sp_count, queue=False, **HID)
|
| 636 |
+
sp_btn.click(smart_text, [sp_in, s_target, s_passes, s_temp, s_mode, s_hi, s_mod, user_state],
|
| 637 |
+
[sp_status, sp_out, sp_prev, sp_scores, sp_file, sp_dl], **HID)
|
| 638 |
+
sd_btn.click(smart_docx, [sd_in, s_target, s_passes, s_temp, s_mode, s_hi, s_mod, user_state],
|
| 639 |
+
[sd_status, sd_prev, sd_scores, sd_file, sd_dl], **HID)
|
| 640 |
+
|
| 641 |
+
# detector
|
| 642 |
+
dp_btn.click(detect_text, [dp_in, d_hi, d_mod, user_state], [dp_status, dp_html, dp_pdf], **HID)
|
| 643 |
+
dd_btn.click(detect_file, [dd_in, d_hi, d_mod, user_state], [dd_status, dd_html, dd_pdf], **HID)
|
| 644 |
+
|
| 645 |
+
# humanizer
|
| 646 |
+
hp_in.change(H.count_words, hp_in, hp_count, queue=False, **HID)
|
| 647 |
+
hp_btn.click(humanize_text, [hp_in, hp_temp, hp_min, hp_mode, user_state],
|
| 648 |
+
[hp_out, hp_file, hp_dl, hp_status, hp_scores], **HID)
|
| 649 |
+
hd_btn.click(humanize_docx, [hd_in, hd_temp, hd_min, hd_mode, user_state],
|
| 650 |
+
[hd_file, hd_dl, hd_status, hd_scores, hd_prev], **HID)
|
| 651 |
+
|
| 652 |
+
demo.queue(default_concurrency_limit=2, max_size=30)
|
| 653 |
+
|
| 654 |
+
if __name__ == "__main__":
|
| 655 |
+
demo.launch(show_api=False, ssr_mode=False, allowed_paths=[str(OUT_DIR)],
|
| 656 |
+
favicon_path=str(FAVICON_PATH) if FAVICON_PATH.exists() else None)
|
auth.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Accounts (email + password, PBKDF2, SQLite) and admin helpers."""
|
| 2 |
+
import hashlib, hmac, os, re, secrets, sqlite3, tempfile
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
|
| 7 |
+
OUT_DIR = Path(tempfile.gettempdir()) / "cs_outputs"
|
| 8 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 9 |
+
|
| 10 |
+
# ---------------------------------------------------------------------------
|
| 11 |
+
# Accounts (email + password, PBKDF2-hashed, SQLite)
|
| 12 |
+
# Stored in /data when persistent storage is enabled; otherwise next to app.py.
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
_DATA_DIR = Path("/data") if Path("/data").is_dir() and os.access("/data", os.W_OK) else Path(__file__).parent
|
| 15 |
+
USERS_DB = Path(os.getenv("USERS_DB", str(_DATA_DIR / "users.db")))
|
| 16 |
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _db():
|
| 20 |
+
con = sqlite3.connect(str(USERS_DB), timeout=10)
|
| 21 |
+
con.execute(
|
| 22 |
+
"CREATE TABLE IF NOT EXISTS users (email TEXT PRIMARY KEY, name TEXT, salt TEXT, "
|
| 23 |
+
"pw TEXT, created TEXT DEFAULT CURRENT_TIMESTAMP, last_login TEXT, runs INTEGER DEFAULT 0)"
|
| 24 |
+
)
|
| 25 |
+
cols = {r[1] for r in con.execute("PRAGMA table_info(users)")}
|
| 26 |
+
if "active" not in cols:
|
| 27 |
+
con.execute("ALTER TABLE users ADD COLUMN active INTEGER DEFAULT 1")
|
| 28 |
+
return con
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Admins: comma-separated emails in the ADMIN_EMAILS secret (Space settings → Variables and secrets).
|
| 32 |
+
ADMIN_EMAILS = {e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def is_admin(user):
|
| 36 |
+
return bool(user and user.get("email", "").lower() in ADMIN_EMAILS)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _hash(password, salt):
|
| 40 |
+
return hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), 200_000).hex()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def create_user(name, email, password, confirm):
|
| 44 |
+
name, email = (name or "").strip(), (email or "").strip().lower()
|
| 45 |
+
if not name:
|
| 46 |
+
return None, "Please enter your name."
|
| 47 |
+
if not _EMAIL_RE.match(email):
|
| 48 |
+
return None, "Please enter a valid email address."
|
| 49 |
+
if len(password or "") < 8:
|
| 50 |
+
return None, "Password must be at least 8 characters."
|
| 51 |
+
if password != confirm:
|
| 52 |
+
return None, "Passwords do not match."
|
| 53 |
+
salt = secrets.token_hex(16)
|
| 54 |
+
with _db() as con:
|
| 55 |
+
if con.execute("SELECT 1 FROM users WHERE email=?", (email,)).fetchone():
|
| 56 |
+
return None, "An account with this email already exists. Please log in."
|
| 57 |
+
con.execute("INSERT INTO users (email, name, salt, pw, last_login) VALUES (?,?,?,?,CURRENT_TIMESTAMP)",
|
| 58 |
+
(email, name, salt, _hash(password, salt)))
|
| 59 |
+
return {"email": email, "name": name}, None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def check_user(email, password):
|
| 63 |
+
email = (email or "").strip().lower()
|
| 64 |
+
with _db() as con:
|
| 65 |
+
row = con.execute("SELECT name, salt, pw, active FROM users WHERE email=?", (email,)).fetchone()
|
| 66 |
+
if not row or not hmac.compare_digest(row[2], _hash(password or "", row[1])):
|
| 67 |
+
return None, "Incorrect email or password."
|
| 68 |
+
if row[3] == 0:
|
| 69 |
+
return None, "This account has been disabled. Please contact CS Software Solutions."
|
| 70 |
+
con.execute("UPDATE users SET last_login=CURRENT_TIMESTAMP WHERE email=?", (email,))
|
| 71 |
+
return {"email": email, "name": row[0]}, None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _count_run(user):
|
| 75 |
+
try:
|
| 76 |
+
with _db() as con:
|
| 77 |
+
con.execute("UPDATE users SET runs = runs + 1 WHERE email=?", (user["email"],))
|
| 78 |
+
except Exception:
|
| 79 |
+
pass
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _require(user):
|
| 83 |
+
if not user or not user.get("email"):
|
| 84 |
+
raise gr.Error("Please sign in to upload documents. Pasting text works without an account.")
|
| 85 |
+
with _db() as con:
|
| 86 |
+
row = con.execute("SELECT active FROM users WHERE email=?", (user["email"],)).fetchone()
|
| 87 |
+
if not row or row[0] == 0:
|
| 88 |
+
raise gr.Error("This account has been disabled.")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _msg(text, ok=False):
|
| 92 |
+
cls = "cs-ok" if ok else "cs-bad"
|
| 93 |
+
return f'<div class="cs-authmsg {cls}">{text}</div>' if text else ""
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
# Admin panel
|
| 98 |
+
# ---------------------------------------------------------------------------
|
| 99 |
+
ADMIN_COLS = ["Name", "Email", "Joined", "Last login", "Runs", "Status", "Role"]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _need_admin(user):
|
| 103 |
+
if not is_admin(user):
|
| 104 |
+
raise gr.Error("Admin access only.")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def admin_stats(user):
|
| 108 |
+
_need_admin(user)
|
| 109 |
+
with _db() as con:
|
| 110 |
+
total = con.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
| 111 |
+
active = con.execute("SELECT COUNT(*) FROM users WHERE active=1").fetchone()[0]
|
| 112 |
+
runs = con.execute("SELECT COALESCE(SUM(runs),0) FROM users").fetchone()[0]
|
| 113 |
+
new7 = con.execute("SELECT COUNT(*) FROM users WHERE created >= datetime('now','-7 days')").fetchone()[0]
|
| 114 |
+
seen7 = con.execute("SELECT COUNT(*) FROM users WHERE last_login >= datetime('now','-7 days')").fetchone()[0]
|
| 115 |
+
tiles = [("Total users", total), ("Active accounts", active), ("Total runs", runs),
|
| 116 |
+
("New (7 days)", new7), ("Logged in (7 days)", seen7)]
|
| 117 |
+
cells = "".join(f'<div class="cs-stat"><div class="n">{v:,}</div><div class="l">{k}</div></div>' for k, v in tiles)
|
| 118 |
+
return f'<div class="cs-stats">{cells}</div>'
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def admin_table(user, query=""):
|
| 122 |
+
_need_admin(user)
|
| 123 |
+
q = f"%{(query or '').strip().lower()}%"
|
| 124 |
+
with _db() as con:
|
| 125 |
+
rows = con.execute(
|
| 126 |
+
"SELECT name, email, created, last_login, runs, active FROM users "
|
| 127 |
+
"WHERE lower(name) LIKE ? OR lower(email) LIKE ? ORDER BY created DESC", (q, q)).fetchall()
|
| 128 |
+
return [[n, e, (c or "")[:16], (l or "")[:16], r, "Active" if a else "Disabled",
|
| 129 |
+
"Admin" if e in ADMIN_EMAILS else "User"] for n, e, c, l, r, a in rows]
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _admin_refresh(user, query, note, ok=True):
|
| 133 |
+
return admin_stats(user), admin_table(user, query), _msg(note, ok=ok)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def admin_search(user, query):
|
| 137 |
+
_need_admin(user)
|
| 138 |
+
return admin_table(user, query)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def admin_set_active(user, email, query, active):
|
| 142 |
+
_need_admin(user)
|
| 143 |
+
email = (email or "").strip().lower()
|
| 144 |
+
if not email:
|
| 145 |
+
return _admin_refresh(user, query, "Enter the user's email first.", ok=False)
|
| 146 |
+
if email in ADMIN_EMAILS and not active:
|
| 147 |
+
return _admin_refresh(user, query, "Admin accounts can't be disabled here.", ok=False)
|
| 148 |
+
with _db() as con:
|
| 149 |
+
n = con.execute("UPDATE users SET active=? WHERE email=?", (1 if active else 0, email)).rowcount
|
| 150 |
+
if not n:
|
| 151 |
+
return _admin_refresh(user, query, f"No user found with {email}.", ok=False)
|
| 152 |
+
return _admin_refresh(user, query, f"{email} is now {'active' if active else 'disabled'}.")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def admin_reset_pw(user, email, new_pw, query):
|
| 156 |
+
_need_admin(user)
|
| 157 |
+
email = (email or "").strip().lower()
|
| 158 |
+
if len(new_pw or "") < 8:
|
| 159 |
+
return _admin_refresh(user, query, "New password must be at least 8 characters.", ok=False)
|
| 160 |
+
salt = secrets.token_hex(16)
|
| 161 |
+
with _db() as con:
|
| 162 |
+
n = con.execute("UPDATE users SET salt=?, pw=? WHERE email=?", (salt, _hash(new_pw, salt), email)).rowcount
|
| 163 |
+
if not n:
|
| 164 |
+
return _admin_refresh(user, query, f"No user found with {email}.", ok=False)
|
| 165 |
+
return _admin_refresh(user, query, f"Password reset for {email}. Share it with them securely.")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def admin_delete(user, email, confirm, query):
|
| 169 |
+
_need_admin(user)
|
| 170 |
+
email = (email or "").strip().lower()
|
| 171 |
+
if (confirm or "").strip().lower() != email or not email:
|
| 172 |
+
return _admin_refresh(user, query, "Type the same email in the confirm box to delete.", ok=False)
|
| 173 |
+
if email in ADMIN_EMAILS:
|
| 174 |
+
return _admin_refresh(user, query, "Admin accounts can't be deleted here.", ok=False)
|
| 175 |
+
with _db() as con:
|
| 176 |
+
n = con.execute("DELETE FROM users WHERE email=?", (email,)).rowcount
|
| 177 |
+
if not n:
|
| 178 |
+
return _admin_refresh(user, query, f"No user found with {email}.", ok=False)
|
| 179 |
+
return _admin_refresh(user, query, f"Deleted {email}.")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def admin_export(user):
|
| 183 |
+
_need_admin(user)
|
| 184 |
+
import csv
|
| 185 |
+
folder = Path(tempfile.mkdtemp(dir=OUT_DIR))
|
| 186 |
+
path = folder / "users.csv"
|
| 187 |
+
with open(path, "w", newline="", encoding="utf-8") as f:
|
| 188 |
+
w = csv.writer(f)
|
| 189 |
+
w.writerow(ADMIN_COLS)
|
| 190 |
+
w.writerows(admin_table(user))
|
| 191 |
+
return str(path)
|
detector.py
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Writing Report — Hugging Face Space
|
| 3 |
+
Detector: coai/roberta-ai-detector-v2 (gated model -> add an HF_TOKEN secret whose account accepted the terms)
|
| 4 |
+
Input: .docx / .pdf / .txt or pasted text -> highlighted PDF report + on-screen preview
|
| 5 |
+
PDF and DOCX inputs keep their original layout: highlights are drawn onto the source pages
|
| 6 |
+
(DOCX is converted to PDF with LibreOffice first), so figures, equations and tables are untouched.
|
| 7 |
+
"""
|
| 8 |
+
import os, re, html, datetime, tempfile, shutil, subprocess
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
|
| 11 |
+
import gradio as gr
|
| 12 |
+
import docx_report as DX
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------- config
|
| 15 |
+
MODEL_ID = os.getenv("DETECTOR_MODEL", "coai/roberta-ai-detector-v2")
|
| 16 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 17 |
+
BRAND = os.getenv("REPORT_BRAND", "CS SOFTWARE SOLUTIONS")
|
| 18 |
+
MIN_PARA_WORDS = 25 # shorter blocks (headings, captions) are not analysed
|
| 19 |
+
WINDOW_WORDS = 90 # detector context per window (model is weak below ~50 words)
|
| 20 |
+
MAX_WINDOW_WORDS = 320 # keeps each window under 512 tokens
|
| 21 |
+
BATCH = 16
|
| 22 |
+
HIGH_COLOR = "#A9E4EE" # cyan
|
| 23 |
+
MOD_COLOR = "#E4DCF5" # light purple
|
| 24 |
+
|
| 25 |
+
try: # ZeroGPU (HF Pro). Falls back to plain CPU/GPU hardware.
|
| 26 |
+
import spaces
|
| 27 |
+
ZERO = True
|
| 28 |
+
except Exception:
|
| 29 |
+
spaces, ZERO = None, False
|
| 30 |
+
|
| 31 |
+
def _duration(texts):
|
| 32 |
+
# ~0.05 s per window on ZeroGPU + load overhead; capped for long theses
|
| 33 |
+
return min(300, 30 + int(len(texts) * 0.05))
|
| 34 |
+
|
| 35 |
+
def gpu(fn):
|
| 36 |
+
return spaces.GPU(duration=_duration)(fn) if ZERO else fn
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------- model
|
| 39 |
+
# Loaded once at startup. On ZeroGPU the model must be created (and moved to
|
| 40 |
+
# "cuda") at module level, not inside the @spaces.GPU function, or it is
|
| 41 |
+
# re-downloaded / re-loaded on every request.
|
| 42 |
+
_tok, _model = None, None
|
| 43 |
+
|
| 44 |
+
def load_model():
|
| 45 |
+
global _tok, _model
|
| 46 |
+
if _model is None:
|
| 47 |
+
import torch
|
| 48 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 49 |
+
_tok = AutoTokenizer.from_pretrained(MODEL_ID, token=HF_TOKEN)
|
| 50 |
+
_model = AutoModelForSequenceClassification.from_pretrained(MODEL_ID, token=HF_TOKEN).eval()
|
| 51 |
+
if ZERO or torch.cuda.is_available():
|
| 52 |
+
_model.to("cuda")
|
| 53 |
+
return _tok, _model
|
| 54 |
+
|
| 55 |
+
if os.getenv("SKIP_MODEL_LOAD") != "1":
|
| 56 |
+
load_model()
|
| 57 |
+
DX.start_lo_init() # fetch newest LibreOffice in the background
|
| 58 |
+
|
| 59 |
+
@gpu
|
| 60 |
+
def score_texts(texts):
|
| 61 |
+
"""P(AI) for each text. Label index 1 = AI-generated (per model card)."""
|
| 62 |
+
import torch
|
| 63 |
+
tok, model = load_model()
|
| 64 |
+
dev = next(model.parameters()).device
|
| 65 |
+
bs = 32 if dev.type == "cuda" else BATCH
|
| 66 |
+
out = []
|
| 67 |
+
for i in range(0, len(texts), bs):
|
| 68 |
+
enc = tok(texts[i:i + bs], return_tensors="pt", truncation=True,
|
| 69 |
+
max_length=512, padding=True).to(dev)
|
| 70 |
+
with torch.no_grad():
|
| 71 |
+
p = torch.softmax(model(**enc).logits.float(), dim=-1)[:, 1]
|
| 72 |
+
out.extend(p.cpu().tolist())
|
| 73 |
+
return out
|
| 74 |
+
|
| 75 |
+
# ---------------------------------------------------------------- extraction
|
| 76 |
+
@dataclass
|
| 77 |
+
class Block:
|
| 78 |
+
text: str
|
| 79 |
+
kind: str = "para" # para | heading | table | skip
|
| 80 |
+
sents: list = field(default_factory=list)
|
| 81 |
+
rows: list = field(default_factory=list) # table cells (list of rows)
|
| 82 |
+
scores: list = field(default_factory=list)
|
| 83 |
+
words: list = field(default_factory=list) # raw words (layout path)
|
| 84 |
+
toks: list = field(default_factory=list) # (page, block, line, bbox) per word
|
| 85 |
+
spans: list = field(default_factory=list) # (start, end) word index per sentence
|
| 86 |
+
el: object = None # w:p element (docx path)
|
| 87 |
+
raw: str = "" # exact paragraph text (docx path)
|
| 88 |
+
cspans: list = field(default_factory=list) # (start, end) char range per sentence
|
| 89 |
+
|
| 90 |
+
REF_HEAD = re.compile(r"^\s*(\d+\.?\s*)?(references|bibliography|works cited|literature cited)\s*$", re.I)
|
| 91 |
+
BULLET = re.compile(r"^\s*([•●▪\-\*–]|\(?[a-z0-9]{1,2}[\)\.])\s+")
|
| 92 |
+
|
| 93 |
+
def _norm(t):
|
| 94 |
+
return re.sub(r"[ \t ]+", " ", t.replace("\r", "")).strip()
|
| 95 |
+
|
| 96 |
+
def extract_docx(path):
|
| 97 |
+
import docx
|
| 98 |
+
from docx.text.paragraph import Paragraph
|
| 99 |
+
from docx.table import Table
|
| 100 |
+
d = docx.Document(path)
|
| 101 |
+
blocks = []
|
| 102 |
+
for child in d.element.body.iterchildren():
|
| 103 |
+
tag = child.tag.split("}")[-1]
|
| 104 |
+
if tag == "p":
|
| 105 |
+
p = Paragraph(child, d)
|
| 106 |
+
t = _norm(p.text)
|
| 107 |
+
if not t:
|
| 108 |
+
continue
|
| 109 |
+
style = (p.style.name or "").lower() if p.style is not None else ""
|
| 110 |
+
kind = "heading" if ("heading" in style or "title" in style) else "para"
|
| 111 |
+
if "list" in style and len(t.split()) < MIN_PARA_WORDS:
|
| 112 |
+
kind = "skip"
|
| 113 |
+
blocks.append(Block(t, kind))
|
| 114 |
+
elif tag == "tbl":
|
| 115 |
+
tbl = Table(child, d)
|
| 116 |
+
rows = []
|
| 117 |
+
for r in tbl.rows:
|
| 118 |
+
cells, prev = [], None
|
| 119 |
+
for c in r.cells: # merged cells repeat the same _tc
|
| 120 |
+
if prev is not None and c._tc is prev:
|
| 121 |
+
continue
|
| 122 |
+
prev = c._tc
|
| 123 |
+
cells.append(_norm(c.text))
|
| 124 |
+
rows.append(cells)
|
| 125 |
+
rows = [r for r in rows if any(r)]
|
| 126 |
+
if rows:
|
| 127 |
+
blocks.append(Block("\n".join(" | ".join(r) for r in rows), "table", rows=rows))
|
| 128 |
+
return blocks
|
| 129 |
+
|
| 130 |
+
def extract_pdf(path):
|
| 131 |
+
import pymupdf as fitz
|
| 132 |
+
blocks = []
|
| 133 |
+
with fitz.open(path) as doc:
|
| 134 |
+
for page in doc:
|
| 135 |
+
h = page.rect.height
|
| 136 |
+
items = [] # (y, Block)
|
| 137 |
+
tboxes = []
|
| 138 |
+
try:
|
| 139 |
+
for tb in page.find_tables().tables:
|
| 140 |
+
rows = [[_norm(c or "") for c in r] for r in tb.extract()]
|
| 141 |
+
rows = [r for r in rows if any(r)]
|
| 142 |
+
if len(rows) >= 2 and max(len(r) for r in rows) >= 2:
|
| 143 |
+
tboxes.append(fitz.Rect(tb.bbox))
|
| 144 |
+
items.append((tb.bbox[1], Block("\n".join(" | ".join(r) for r in rows),
|
| 145 |
+
"table", rows=rows)))
|
| 146 |
+
except Exception:
|
| 147 |
+
pass
|
| 148 |
+
for b in page.get_text("blocks", sort=True):
|
| 149 |
+
x0, y0, x1, y1, t, _, btype = b
|
| 150 |
+
if btype != 0:
|
| 151 |
+
continue
|
| 152 |
+
if y1 < h * 0.06 or y0 > h * 0.94: # running header / footer
|
| 153 |
+
continue
|
| 154 |
+
r = fitz.Rect(x0, y0, x1, y1)
|
| 155 |
+
if any(tr.intersects(r) and (tr & r).get_area() > 0.5 * r.get_area() for tr in tboxes):
|
| 156 |
+
continue # text already captured in a table
|
| 157 |
+
t = _norm(re.sub(r"-\n(?=[a-z])", "", t).replace("\n", " "))
|
| 158 |
+
if t:
|
| 159 |
+
items.append((y0, Block(t)))
|
| 160 |
+
blocks.extend(b for _, b in sorted(items, key=lambda x: x[0]))
|
| 161 |
+
# re-join paragraphs split across page / column breaks
|
| 162 |
+
merged = []
|
| 163 |
+
for b in blocks:
|
| 164 |
+
if merged and merged[-1].kind == "para" and not re.search(r"[.!?:\"”)]$", merged[-1].text) \
|
| 165 |
+
and b.text[:1].islower():
|
| 166 |
+
merged[-1].text += " " + b.text
|
| 167 |
+
else:
|
| 168 |
+
merged.append(b)
|
| 169 |
+
return merged
|
| 170 |
+
|
| 171 |
+
def _dehyphen(t):
|
| 172 |
+
return re.sub(r"(\w)- ([a-z])", r"\1\2", t)
|
| 173 |
+
|
| 174 |
+
def extract_pdf_layout(path):
|
| 175 |
+
"""Paragraph blocks that remember where every word sits on the page."""
|
| 176 |
+
import pymupdf as fitz
|
| 177 |
+
blocks = []
|
| 178 |
+
with fitz.open(path) as doc:
|
| 179 |
+
for pno, page in enumerate(doc):
|
| 180 |
+
h = page.rect.height
|
| 181 |
+
tables, tboxes = [], []
|
| 182 |
+
try:
|
| 183 |
+
for tb in page.find_tables().tables:
|
| 184 |
+
rows = [[_norm(c or "") for c in r] for r in tb.extract()]
|
| 185 |
+
rows = [r for r in rows if any(r)]
|
| 186 |
+
if len(rows) >= 2 and max(len(r) for r in rows) >= 2:
|
| 187 |
+
tboxes.append(fitz.Rect(tb.bbox))
|
| 188 |
+
tables.append((tb.bbox[1], Block("\n".join(" | ".join(r) for r in rows),
|
| 189 |
+
"table", rows=rows)))
|
| 190 |
+
except Exception:
|
| 191 |
+
pass
|
| 192 |
+
bywords = {}
|
| 193 |
+
for w in page.get_text("words"):
|
| 194 |
+
bywords.setdefault(w[5], []).append(w)
|
| 195 |
+
page_blocks = []
|
| 196 |
+
for b in page.get_text("blocks", sort=True):
|
| 197 |
+
x0, y0, x1, y1, _, bno, btype = b
|
| 198 |
+
if btype != 0 or bno not in bywords:
|
| 199 |
+
continue
|
| 200 |
+
if y1 < h * 0.06 or y0 > h * 0.94: # running header / footer
|
| 201 |
+
continue
|
| 202 |
+
r = fitz.Rect(x0, y0, x1, y1)
|
| 203 |
+
if any(tr.intersects(r) and (tr & r).get_area() > 0.5 * r.get_area() for tr in tboxes):
|
| 204 |
+
continue # part of a table
|
| 205 |
+
ws = sorted(bywords[bno], key=lambda w: (w[6], w[7]))
|
| 206 |
+
words = [w[4] for w in ws]
|
| 207 |
+
toks = [(pno, bno, w[6], (w[0], w[1], w[2], w[3])) for w in ws]
|
| 208 |
+
page_blocks.append((y0, Block(" ".join(words), words=words, toks=toks)))
|
| 209 |
+
# place detected tables at their vertical position
|
| 210 |
+
for ty, tblk in sorted(tables, key=lambda x: x[0]):
|
| 211 |
+
idx = next((i for i, (y, _) in enumerate(page_blocks) if y > ty), len(page_blocks))
|
| 212 |
+
page_blocks.insert(idx, (ty, tblk))
|
| 213 |
+
blocks.extend(b for _, b in page_blocks)
|
| 214 |
+
# re-join paragraphs split across page / column breaks
|
| 215 |
+
merged = []
|
| 216 |
+
for b in blocks:
|
| 217 |
+
if merged and b.kind == "para" and merged[-1].kind == "para" and merged[-1].toks \
|
| 218 |
+
and not re.search(r"[.!?:\"”)]$", merged[-1].text) and b.text[:1].islower():
|
| 219 |
+
m = merged[-1]
|
| 220 |
+
m.text += " " + b.text
|
| 221 |
+
m.words += b.words
|
| 222 |
+
m.toks += b.toks
|
| 223 |
+
else:
|
| 224 |
+
merged.append(b)
|
| 225 |
+
return merged
|
| 226 |
+
|
| 227 |
+
def layout_sentences(b):
|
| 228 |
+
"""Sentence spans over the block's word list (+ clean display text)."""
|
| 229 |
+
sents = split_sentences(" ".join(b.words))
|
| 230 |
+
counts = [len(s.split()) for s in sents]
|
| 231 |
+
if sum(counts) != len(b.words):
|
| 232 |
+
sents, counts = [" ".join(b.words)], [len(b.words)]
|
| 233 |
+
spans, i = [], 0
|
| 234 |
+
for c in counts:
|
| 235 |
+
spans.append((i, i + c))
|
| 236 |
+
i += c
|
| 237 |
+
b.spans = spans
|
| 238 |
+
b.sents = [_dehyphen(s) for s in sents]
|
| 239 |
+
|
| 240 |
+
def extract_docx_live(doc):
|
| 241 |
+
"""Blocks bound to the paragraphs of an open python-docx Document."""
|
| 242 |
+
blocks = []
|
| 243 |
+
for kind, obj in DX.iter_body(doc):
|
| 244 |
+
if kind == "p":
|
| 245 |
+
raw = DX.para_text(obj._p)
|
| 246 |
+
t = re.sub(r"\s+", " ", raw).strip()
|
| 247 |
+
if not t:
|
| 248 |
+
continue
|
| 249 |
+
style = (obj.style.name or "").lower() if obj.style is not None else ""
|
| 250 |
+
k = "heading" if ("heading" in style or "title" in style) else "para"
|
| 251 |
+
if ("list" in style or "caption" in style) and len(t.split()) < MIN_PARA_WORDS:
|
| 252 |
+
k = "skip"
|
| 253 |
+
blocks.append(Block(t, k, el=obj._p, raw=raw))
|
| 254 |
+
else:
|
| 255 |
+
rows = []
|
| 256 |
+
for r in obj.rows:
|
| 257 |
+
cells, prev = [], None
|
| 258 |
+
for c in r.cells:
|
| 259 |
+
if prev is not None and c._tc is prev:
|
| 260 |
+
continue
|
| 261 |
+
prev = c._tc
|
| 262 |
+
cells.append(_norm(c.text))
|
| 263 |
+
rows.append(cells)
|
| 264 |
+
rows = [r for r in rows if any(r)]
|
| 265 |
+
if rows:
|
| 266 |
+
blocks.append(Block("\n".join(" | ".join(r) for r in rows), "table", rows=rows))
|
| 267 |
+
return blocks
|
| 268 |
+
|
| 269 |
+
def extract_text(raw):
|
| 270 |
+
return [Block(_norm(p)) for p in re.split(r"\n\s*\n", raw.replace("\r", "")) if _norm(p)]
|
| 271 |
+
|
| 272 |
+
def classify(blocks):
|
| 273 |
+
in_refs = False
|
| 274 |
+
for b in blocks:
|
| 275 |
+
if REF_HEAD.match(b.text):
|
| 276 |
+
b.kind, in_refs = "heading", True
|
| 277 |
+
continue
|
| 278 |
+
if in_refs:
|
| 279 |
+
b.kind = "skip"
|
| 280 |
+
continue
|
| 281 |
+
if b.kind in ("table", "skip", "heading"):
|
| 282 |
+
continue
|
| 283 |
+
wc = len(b.text.split())
|
| 284 |
+
wordy = sum(1 for w in b.text.split() if re.fullmatch(r"[A-Za-z][A-Za-z'’\-]+[.,;:)]?", w))
|
| 285 |
+
if wc and wordy / wc < 0.55: # equations, symbols, numeric lists
|
| 286 |
+
b.kind = "skip"
|
| 287 |
+
continue
|
| 288 |
+
if re.match(r"^(fig(ure)?|table)\s*\.?\s*\d", b.text, re.I) and wc < 40:
|
| 289 |
+
b.kind = "skip"
|
| 290 |
+
elif BULLET.match(b.text) and wc < MIN_PARA_WORDS:
|
| 291 |
+
b.kind = "skip"
|
| 292 |
+
elif wc < MIN_PARA_WORDS:
|
| 293 |
+
b.kind = "heading" if wc <= 14 and not b.text.endswith(".") else "skip"
|
| 294 |
+
return blocks
|
| 295 |
+
|
| 296 |
+
# ---------------------------------------------------------------- sentences
|
| 297 |
+
_ABBR = r"(?:et al|e\.g|i\.e|etc|vs|Fig|Figs|Eq|Eqs|Ref|Refs|No|Vol|pp|Dr|Mr|Mrs|Ms|Prof|approx|cf|Sec|Ch)"
|
| 298 |
+
|
| 299 |
+
def split_sentences(t):
|
| 300 |
+
t = re.sub(rf"\b({_ABBR})\.", lambda m: m.group(1).replace(".", "<DOT>") + "<DOT>", t)
|
| 301 |
+
t = re.sub(r"(\d)\.(\d)", r"\1<DOT>\2", t)
|
| 302 |
+
parts = re.split(r"(?<=[.!?])[\"”’)]?\s+(?=[A-Z0-9\[\(“\"])", t)
|
| 303 |
+
return [p.replace("<DOT>", ".").strip() for p in parts if p.strip()]
|
| 304 |
+
|
| 305 |
+
# ---------------------------------------------------------------- analysis
|
| 306 |
+
def make_windows(lengths):
|
| 307 |
+
"""Overlapping sentence windows of ~WINDOW_WORDS words (≈50% overlap)."""
|
| 308 |
+
windows, i, n = [], 0, len(lengths)
|
| 309 |
+
while i < n:
|
| 310 |
+
j, wc = i + 1, lengths[i]
|
| 311 |
+
while j < n and wc < WINDOW_WORDS and wc + lengths[j] <= MAX_WINDOW_WORDS:
|
| 312 |
+
wc += lengths[j]
|
| 313 |
+
j += 1
|
| 314 |
+
windows.append((i, j))
|
| 315 |
+
if j >= n:
|
| 316 |
+
break
|
| 317 |
+
i = max(i + 1, i + (j - i) // 2)
|
| 318 |
+
return windows
|
| 319 |
+
|
| 320 |
+
def analyse(blocks, scorer=None):
|
| 321 |
+
scorer = scorer or score_texts
|
| 322 |
+
stream = [] # (block_idx, sent_idx, text, wordcount)
|
| 323 |
+
for bi, b in enumerate(blocks):
|
| 324 |
+
if b.kind != "para":
|
| 325 |
+
continue
|
| 326 |
+
if b.toks:
|
| 327 |
+
layout_sentences(b)
|
| 328 |
+
elif b.el is not None:
|
| 329 |
+
raws = split_sentences(b.raw)
|
| 330 |
+
b.cspans = DX.sentence_char_spans(b.raw, raws)
|
| 331 |
+
b.sents = [re.sub(r"\s+", " ", x).strip() for x in raws]
|
| 332 |
+
else:
|
| 333 |
+
b.sents = split_sentences(b.text)
|
| 334 |
+
b.scores = [0.0] * len(b.sents)
|
| 335 |
+
for si, s in enumerate(b.sents):
|
| 336 |
+
stream.append((bi, si, s, len(s.split())))
|
| 337 |
+
if not stream:
|
| 338 |
+
return blocks
|
| 339 |
+
|
| 340 |
+
windows = make_windows([x[3] for x in stream])
|
| 341 |
+
probs = scorer([" ".join(stream[k][2] for k in range(a, b)) for a, b in windows])
|
| 342 |
+
|
| 343 |
+
acc, cnt = [0.0] * len(stream), [0] * len(stream)
|
| 344 |
+
for (a, b), p in zip(windows, probs):
|
| 345 |
+
for k in range(a, b):
|
| 346 |
+
acc[k] += p
|
| 347 |
+
cnt[k] += 1
|
| 348 |
+
for k, (bi, si, _, _) in enumerate(stream):
|
| 349 |
+
blocks[bi].scores[si] = acc[k] / max(cnt[k], 1)
|
| 350 |
+
|
| 351 |
+
# light paragraph smoothing -> less speckled highlighting
|
| 352 |
+
for b in blocks:
|
| 353 |
+
if b.kind == "para" and b.scores:
|
| 354 |
+
m = sum(b.scores) / len(b.scores)
|
| 355 |
+
b.scores = [0.7 * s + 0.3 * m for s in b.scores]
|
| 356 |
+
return blocks
|
| 357 |
+
|
| 358 |
+
def summarise(blocks, hi, mod):
|
| 359 |
+
total = hi_w = mod_w = 0
|
| 360 |
+
for b in blocks:
|
| 361 |
+
if b.kind != "para":
|
| 362 |
+
continue
|
| 363 |
+
for s, p in zip(b.sents, b.scores):
|
| 364 |
+
w = len(s.split())
|
| 365 |
+
total += w
|
| 366 |
+
if p >= hi:
|
| 367 |
+
hi_w += w
|
| 368 |
+
elif p >= mod:
|
| 369 |
+
mod_w += w
|
| 370 |
+
pct = round(100 * (hi_w + mod_w) / total) if total else 0
|
| 371 |
+
return dict(pct=pct, analysed=total, high=hi_w, moderate=mod_w,
|
| 372 |
+
all_words=sum(len(b.text.split()) for b in blocks))
|
| 373 |
+
|
| 374 |
+
def band(pct):
|
| 375 |
+
if pct < 20:
|
| 376 |
+
return "Low likelihood of AI-generated content"
|
| 377 |
+
if pct <= 50:
|
| 378 |
+
return "Moderate likelihood of AI-generated content"
|
| 379 |
+
return "High likelihood of AI-generated content"
|
| 380 |
+
|
| 381 |
+
# ---------------------------------------------------------------- PDF report
|
| 382 |
+
def build_pdf(blocks, stats, fname, fsize_kb, hi, mod, out_path, summary_only=False):
|
| 383 |
+
from reportlab.lib.pagesizes import A4
|
| 384 |
+
from reportlab.lib.units import mm
|
| 385 |
+
from reportlab.lib import colors
|
| 386 |
+
from reportlab.lib.styles import ParagraphStyle
|
| 387 |
+
from reportlab.platypus import (BaseDocTemplate, Frame, PageTemplate, Paragraph,
|
| 388 |
+
Spacer, Table, TableStyle, PageBreak)
|
| 389 |
+
from reportlab.pdfgen import canvas as rl_canvas
|
| 390 |
+
from reportlab.pdfbase import pdfmetrics
|
| 391 |
+
from reportlab.pdfbase.ttfonts import TTFont
|
| 392 |
+
|
| 393 |
+
serif, serif_b, sans, sans_b = "Times-Roman", "Times-Bold", "Helvetica", "Helvetica-Bold"
|
| 394 |
+
fdir = "/usr/share/fonts/truetype/dejavu/"
|
| 395 |
+
try:
|
| 396 |
+
for a, f in [("Serif", "DejaVuSerif.ttf"), ("SerifB", "DejaVuSerif-Bold.ttf"),
|
| 397 |
+
("Sans", "DejaVuSans.ttf"), ("SansB", "DejaVuSans-Bold.ttf")]:
|
| 398 |
+
pdfmetrics.registerFont(TTFont(a, fdir + f))
|
| 399 |
+
serif, serif_b, sans, sans_b = "Serif", "SerifB", "Sans", "SansB" # Unicode-safe
|
| 400 |
+
except Exception:
|
| 401 |
+
pass
|
| 402 |
+
|
| 403 |
+
now = datetime.datetime.now(datetime.timezone.utc).strftime("%B %d, %Y at %I:%M %p UTC")
|
| 404 |
+
|
| 405 |
+
class NumberedCanvas(rl_canvas.Canvas):
|
| 406 |
+
def __init__(self, *a, **k):
|
| 407 |
+
super().__init__(*a, **k)
|
| 408 |
+
self._saved = []
|
| 409 |
+
def showPage(self):
|
| 410 |
+
self._saved.append(dict(self.__dict__))
|
| 411 |
+
self._startPage()
|
| 412 |
+
def save(self):
|
| 413 |
+
total = len(self._saved)
|
| 414 |
+
for st in self._saved:
|
| 415 |
+
self.__dict__.update(st)
|
| 416 |
+
w, h = A4
|
| 417 |
+
self.setFont(sans, 8)
|
| 418 |
+
self.setFillColor(colors.HexColor("#555555"))
|
| 419 |
+
self.drawString(18 * mm, h - 12 * mm, BRAND)
|
| 420 |
+
self.drawRightString(w - 18 * mm, h - 12 * mm, "AI WRITING REPORT")
|
| 421 |
+
if not summary_only:
|
| 422 |
+
self.drawCentredString(w / 2, 10 * mm, f"Page {self._pageNumber} of {total}")
|
| 423 |
+
super().showPage()
|
| 424 |
+
super().save()
|
| 425 |
+
|
| 426 |
+
_n = [0]
|
| 427 |
+
def S(**k):
|
| 428 |
+
_n[0] += 1
|
| 429 |
+
return ParagraphStyle(name=f"s{_n[0]}", **k)
|
| 430 |
+
|
| 431 |
+
body = S(fontName=serif, fontSize=12, leading=24, alignment=4, firstLineIndent=18)
|
| 432 |
+
head = S(fontName=serif_b, fontSize=12, leading=22, spaceBefore=8, spaceAfter=2)
|
| 433 |
+
plain = S(fontName=serif, fontSize=11, leading=16, textColor=colors.HexColor("#333333"), spaceAfter=6)
|
| 434 |
+
tbls = S(fontName=serif, fontSize=9, leading=12, textColor=colors.HexColor("#555555"), spaceAfter=6)
|
| 435 |
+
h1 = S(fontName=sans_b, fontSize=26, leading=32, spaceAfter=6)
|
| 436 |
+
h2 = S(fontName=sans_b, fontSize=12, leading=16, spaceBefore=12, spaceAfter=4)
|
| 437 |
+
small = S(fontName=sans, fontSize=9.5, leading=13.5, textColor=colors.HexColor("#333333"))
|
| 438 |
+
esc = lambda t: html.escape(t, quote=False)
|
| 439 |
+
cell = S(fontName=serif, fontSize=9.5, leading=12)
|
| 440 |
+
cell_b = S(fontName=serif_b, fontSize=9.5, leading=12)
|
| 441 |
+
|
| 442 |
+
def make_table(rows):
|
| 443 |
+
ncol = max(len(r) for r in rows)
|
| 444 |
+
rows = [r + [""] * (ncol - len(r)) for r in rows]
|
| 445 |
+
# column widths proportional to content length, within page width
|
| 446 |
+
lens = [max(min(len(r[c]), 60) for r in rows) + 4 for c in range(ncol)]
|
| 447 |
+
W = 160 * mm
|
| 448 |
+
widths = [W * l / sum(lens) for l in lens]
|
| 449 |
+
data = [[Paragraph(esc(c), cell_b if i == 0 else cell) for c in r] for i, r in enumerate(rows)]
|
| 450 |
+
t = Table(data, colWidths=widths, repeatRows=1, hAlign="CENTER")
|
| 451 |
+
t.setStyle(TableStyle([
|
| 452 |
+
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#9a9a9a")),
|
| 453 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#efefef")),
|
| 454 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 455 |
+
("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
| 456 |
+
("LEFTPADDING", (0, 0), (-1, -1), 4), ("RIGHTPADDING", (0, 0), (-1, -1), 4)]))
|
| 457 |
+
t.spaceBefore, t.spaceAfter = 4, 10
|
| 458 |
+
return t
|
| 459 |
+
|
| 460 |
+
story = [Spacer(1, 10), Paragraph(f"{stats['pct']}% detected as AI", h1),
|
| 461 |
+
Paragraph("The percentage indicates the share of analysed text that the detector classified as "
|
| 462 |
+
"likely AI-generated (high plus moderate likelihood).", small),
|
| 463 |
+
Paragraph(f"<b>{band(stats['pct'])}</b>", S(fontName=sans, fontSize=10.5, leading=16, spaceBefore=6)),
|
| 464 |
+
Paragraph("Important Notice", h2),
|
| 465 |
+
Paragraph("This percentage is a probabilistic estimate from a machine-learning classifier and should be "
|
| 466 |
+
"treated as an indicator, not a conclusion. Results vary with writing style, subject matter, "
|
| 467 |
+
"non-native English and document structure, and false positives occur. Use this report as "
|
| 468 |
+
"one reference point alongside human judgement.", small),
|
| 469 |
+
Paragraph("Guidelines", h2),
|
| 470 |
+
Paragraph("The report analyses long-form prose. Highlight colours show the likelihood of a passage being "
|
| 471 |
+
"flagged: cyan for high, light purple for moderate, unhighlighted for low. Non-qualifying "
|
| 472 |
+
"content (headings, short bullet points, tables, captions, references) is not analysed. "
|
| 473 |
+
"Scanned or image-only pages cannot be read.", small),
|
| 474 |
+
Paragraph("Details", h2)]
|
| 475 |
+
t = Table([["File", esc(fname)[:60]], ["Word count", f"{stats['all_words']:,}"],
|
| 476 |
+
["Words analysed", f"{stats['analysed']:,}"], ["Document size", f"{fsize_kb:.1f} KB"],
|
| 477 |
+
["Detector", MODEL_ID]], colWidths=[40 * mm, 110 * mm])
|
| 478 |
+
t.setStyle(TableStyle([("FONT", (0, 0), (-1, -1), sans, 9.5), ("FONT", (0, 0), (0, -1), sans_b, 9.5),
|
| 479 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 3)]))
|
| 480 |
+
story += [t, Paragraph("Score Interpretation", h2)]
|
| 481 |
+
t = Table([["0% – 19%", "Low likelihood of AI-generated content"],
|
| 482 |
+
["20% – 50%", "Moderate likelihood of AI-generated content"],
|
| 483 |
+
["51% – 100%", "High likelihood of AI-generated content"]], colWidths=[30 * mm, 120 * mm])
|
| 484 |
+
t.setStyle(TableStyle([("FONT", (0, 0), (-1, -1), sans, 9.5), ("BOTTOMPADDING", (0, 0), (-1, -1), 3)]))
|
| 485 |
+
story += [t, Paragraph("Highlight Legend", h2)]
|
| 486 |
+
t = Table([["", f"High likelihood of being flagged (≥ {hi:.0%})"],
|
| 487 |
+
["", f"Moderate likelihood of being flagged ({mod:.0%} – {hi:.0%})"],
|
| 488 |
+
["", f"Low likelihood of being flagged (< {mod:.0%})"]],
|
| 489 |
+
colWidths=[10 * mm, 140 * mm], rowHeights=6 * mm)
|
| 490 |
+
t.setStyle(TableStyle([("FONT", (0, 0), (-1, -1), sans, 9.5),
|
| 491 |
+
("BACKGROUND", (0, 0), (0, 0), colors.HexColor(HIGH_COLOR)),
|
| 492 |
+
("BACKGROUND", (0, 1), (0, 1), colors.HexColor(MOD_COLOR)),
|
| 493 |
+
("BOX", (0, 2), (0, 2), 0.5, colors.HexColor("#999999")),
|
| 494 |
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE")]))
|
| 495 |
+
story += [t, Spacer(1, 14), Paragraph(f"Report generated: {now}", small)]
|
| 496 |
+
if summary_only:
|
| 497 |
+
blocks = []
|
| 498 |
+
else:
|
| 499 |
+
story.append(PageBreak())
|
| 500 |
+
|
| 501 |
+
for b in blocks:
|
| 502 |
+
if b.kind == "para":
|
| 503 |
+
parts = []
|
| 504 |
+
for s, p in zip(b.sents, b.scores):
|
| 505 |
+
s = esc(s)
|
| 506 |
+
if p >= hi:
|
| 507 |
+
s = f'<font backColor="{HIGH_COLOR}">{s}</font>'
|
| 508 |
+
elif p >= mod:
|
| 509 |
+
s = f'<font backColor="{MOD_COLOR}">{s}</font>'
|
| 510 |
+
parts.append(s)
|
| 511 |
+
story.append(Paragraph(" ".join(parts), body))
|
| 512 |
+
elif b.kind == "heading":
|
| 513 |
+
story.append(Paragraph(esc(b.text), head))
|
| 514 |
+
elif b.kind == "table" and b.rows:
|
| 515 |
+
story.append(make_table(b.rows))
|
| 516 |
+
elif b.kind == "table":
|
| 517 |
+
story.append(Paragraph(esc(b.text).replace("\n", "<br/>"), tbls))
|
| 518 |
+
else:
|
| 519 |
+
story.append(Paragraph(esc(b.text), plain))
|
| 520 |
+
|
| 521 |
+
doc = BaseDocTemplate(out_path, pagesize=A4, leftMargin=25 * mm, rightMargin=25 * mm,
|
| 522 |
+
topMargin=22 * mm, bottomMargin=20 * mm, title="AI Writing Report")
|
| 523 |
+
doc.addPageTemplates([PageTemplate(id="p", frames=[
|
| 524 |
+
Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="f")])])
|
| 525 |
+
doc.build(story, canvasmaker=NumberedCanvas)
|
| 526 |
+
return out_path
|
| 527 |
+
|
| 528 |
+
def build_overlay_pdf(src_pdf, blocks, stats, fname, fsize_kb, hi, mod, out_path):
|
| 529 |
+
"""Summary page + the ORIGINAL pages with highlight annotations."""
|
| 530 |
+
import pymupdf as fitz
|
| 531 |
+
rgb = lambda hx: tuple(int(hx[i:i + 2], 16) / 255 for i in (1, 3, 5))
|
| 532 |
+
hc, mc = rgb(HIGH_COLOR), rgb(MOD_COLOR)
|
| 533 |
+
src = fitz.open(src_pdf)
|
| 534 |
+
pages = {} # keep Page objects alive while annotating
|
| 535 |
+
for b in blocks:
|
| 536 |
+
if b.kind != "para" or not b.spans:
|
| 537 |
+
continue
|
| 538 |
+
for (a, e), p in zip(b.spans, b.scores):
|
| 539 |
+
if p < mod:
|
| 540 |
+
continue
|
| 541 |
+
col = hc if p >= hi else mc
|
| 542 |
+
lines = {}
|
| 543 |
+
for pno, bno, lno, bbox in b.toks[a:e]:
|
| 544 |
+
key = (pno, bno, lno)
|
| 545 |
+
lines[key] = lines[key] | fitz.Rect(bbox) if key in lines else fitz.Rect(bbox)
|
| 546 |
+
for (pno, _, _), r in lines.items():
|
| 547 |
+
pg = pages.get(pno) or pages.setdefault(pno, src[pno])
|
| 548 |
+
an = pg.add_highlight_annot(r)
|
| 549 |
+
an.set_colors(stroke=col)
|
| 550 |
+
an.update()
|
| 551 |
+
|
| 552 |
+
summ = out_path + ".summary.pdf"
|
| 553 |
+
build_pdf([], stats, fname, fsize_kb, hi, mod, summ, summary_only=True)
|
| 554 |
+
final = fitz.open(summ)
|
| 555 |
+
n_summary = len(final)
|
| 556 |
+
final.insert_pdf(src, annots=True)
|
| 557 |
+
n = len(final)
|
| 558 |
+
grey = (0.35, 0.35, 0.35)
|
| 559 |
+
for i, page in enumerate(final):
|
| 560 |
+
if page.rotation:
|
| 561 |
+
continue
|
| 562 |
+
w, h = page.rect.width, page.rect.height
|
| 563 |
+
label = f"Page {i + 1} of {n}"
|
| 564 |
+
page.insert_text(((w - fitz.get_text_length(label, fontsize=7)) / 2, h - 8), label,
|
| 565 |
+
fontsize=7, color=grey, overlay=True)
|
| 566 |
+
if i >= n_summary:
|
| 567 |
+
page.insert_text((20, 11), BRAND, fontsize=6.5, color=grey, overlay=True)
|
| 568 |
+
rt = "AI WRITING REPORT"
|
| 569 |
+
page.insert_text((w - 20 - fitz.get_text_length(rt, fontsize=6.5), 11), rt,
|
| 570 |
+
fontsize=6.5, color=grey, overlay=True)
|
| 571 |
+
final.save(out_path, garbage=3, deflate=True)
|
| 572 |
+
final.close(); src.close()
|
| 573 |
+
os.remove(summ)
|
| 574 |
+
return out_path
|
| 575 |
+
|
| 576 |
+
# ---------------------------------------------------------------- HTML preview
|
| 577 |
+
def build_html(blocks, stats, hi, mod):
|
| 578 |
+
col = "#c0392b" if stats["pct"] > 50 else "#d68910" if stats["pct"] >= 20 else "#1e8449"
|
| 579 |
+
out = [f"""<div style="font-family:system-ui;padding:14px 18px;border:1px solid #ddd;border-radius:10px;margin-bottom:12px">
|
| 580 |
+
<div style="font-size:34px;font-weight:700;color:{col}">{stats['pct']}% detected as AI</div>
|
| 581 |
+
<div style="color:#555">{band(stats['pct'])} · {stats['analysed']:,} of {stats['all_words']:,} words analysed</div>
|
| 582 |
+
<div style="margin-top:6px;font-size:13px;color:#111"><span style="background:{HIGH_COLOR};padding:1px 8px">High</span>
|
| 583 |
+
<span style="background:{MOD_COLOR};padding:1px 8px">Moderate</span>
|
| 584 |
+
<span style="padding:1px 8px;border:1px solid #ccc">Low</span></div></div>
|
| 585 |
+
<div style="font-family:Georgia,serif;line-height:1.9;max-height:640px;overflow:auto;padding:4px 12px;color:#111;background:#fff;border-radius:8px">"""]
|
| 586 |
+
for b in blocks:
|
| 587 |
+
if b.kind == "para":
|
| 588 |
+
segs = []
|
| 589 |
+
for s, p in zip(b.sents, b.scores):
|
| 590 |
+
bg = HIGH_COLOR if p >= hi else MOD_COLOR if p >= mod else "transparent"
|
| 591 |
+
segs.append(f'<span title="P(AI) = {p:.2f}" style="background:{bg}">{html.escape(s)}</span>')
|
| 592 |
+
out.append("<p style='text-align:justify'>" + " ".join(segs) + "</p>")
|
| 593 |
+
elif b.kind == "heading":
|
| 594 |
+
out.append(f"<p><b>{html.escape(b.text)}</b></p>")
|
| 595 |
+
elif b.kind == "table" and b.rows:
|
| 596 |
+
td = "border:1px solid #bbb;padding:3px 8px;text-align:left;vertical-align:top"
|
| 597 |
+
trs = []
|
| 598 |
+
for i, r in enumerate(b.rows):
|
| 599 |
+
tag = "th" if i == 0 else "td"
|
| 600 |
+
bg = "background:#f0f0f0;" if i == 0 else ""
|
| 601 |
+
trs.append("<tr>" + "".join(f"<{tag} style='{bg}{td}'>{html.escape(c)}</{tag}>" for c in r) + "</tr>")
|
| 602 |
+
out.append("<table style='border-collapse:collapse;margin:6px auto 14px;font-size:0.88em'>"
|
| 603 |
+
+ "".join(trs) + "</table>")
|
| 604 |
+
else:
|
| 605 |
+
out.append(f"<p style='color:#777;font-size:0.9em'>{html.escape(b.text).replace(chr(10), '<br>')}</p>")
|
| 606 |
+
out.append("</div>")
|
| 607 |
+
return "".join(out)
|
| 608 |
+
|
| 609 |
+
# ---------------------------------------------------------------- pipeline
|
| 610 |
+
def load_blocks(file, pasted):
|
| 611 |
+
"""Returns (blocks, fname, size_kb, source_pdf_or_None, docx_document_or_None)."""
|
| 612 |
+
src_pdf, doc = None, None
|
| 613 |
+
if file is not None:
|
| 614 |
+
path = file if isinstance(file, str) else file.name
|
| 615 |
+
ext = os.path.splitext(path)[1].lower()
|
| 616 |
+
fname, size_kb = os.path.basename(path), os.path.getsize(path) / 1024
|
| 617 |
+
if ext == ".docx":
|
| 618 |
+
try:
|
| 619 |
+
doc = DX.open_clean(path)
|
| 620 |
+
except Exception:
|
| 621 |
+
raise gr.Error("Could not open this .docx (is it a valid Word file, not .doc?).")
|
| 622 |
+
blocks = extract_docx_live(doc)
|
| 623 |
+
elif ext == ".pdf":
|
| 624 |
+
src_pdf = path
|
| 625 |
+
blocks = extract_pdf_layout(path)
|
| 626 |
+
elif ext in (".txt", ".md"):
|
| 627 |
+
with open(path, encoding="utf-8", errors="ignore") as fh:
|
| 628 |
+
blocks = extract_text(fh.read())
|
| 629 |
+
else:
|
| 630 |
+
raise gr.Error("Upload a .docx, .pdf or .txt file.")
|
| 631 |
+
elif pasted and pasted.strip():
|
| 632 |
+
blocks, fname, size_kb = extract_text(pasted), "Pasted text", len(pasted.encode()) / 1024
|
| 633 |
+
else:
|
| 634 |
+
raise gr.Error("Upload a file or paste text.")
|
| 635 |
+
return classify(blocks), fname, size_kb, src_pdf, doc
|
| 636 |
+
|
| 637 |
+
def run(file, pasted, hi, mod, progress=gr.Progress()):
|
| 638 |
+
if mod >= hi:
|
| 639 |
+
raise gr.Error("Moderate threshold must be below the high threshold.")
|
| 640 |
+
progress(0.05, desc="Reading document")
|
| 641 |
+
blocks, fname, size_kb, src_pdf, doc = load_blocks(file, pasted)
|
| 642 |
+
if not any(b.kind == "para" for b in blocks):
|
| 643 |
+
raise gr.Error("No qualifying prose found (needs paragraphs of 25+ words). "
|
| 644 |
+
"Scanned/image-only PDFs have no readable text.")
|
| 645 |
+
progress(0.2, desc="Running detector")
|
| 646 |
+
blocks = analyse(blocks)
|
| 647 |
+
stats = summarise(blocks, hi, mod)
|
| 648 |
+
base = re.sub(r"[^\w\-]+", "_", os.path.splitext(fname)[0])[:40] or "document"
|
| 649 |
+
work = tempfile.mkdtemp()
|
| 650 |
+
pdf = os.path.join(work, f"AI_Report_{base}.pdf")
|
| 651 |
+
warn = ""
|
| 652 |
+
|
| 653 |
+
if doc is not None: # ---- Word: inject, then render
|
| 654 |
+
progress(0.7, desc="Highlighting the Word file")
|
| 655 |
+
for b in blocks:
|
| 656 |
+
if b.kind == "para" and b.el is not None and b.cspans:
|
| 657 |
+
spans = [(sp[0], sp[1], (HIGH_COLOR if p >= hi else MOD_COLOR).lstrip("#"))
|
| 658 |
+
for sp, p in zip(b.cspans, b.scores) if sp and p >= mod]
|
| 659 |
+
DX.colour_paragraph(b.el, spans)
|
| 660 |
+
docx_tmp = os.path.join(work, f"{base}_highlighted.docx")
|
| 661 |
+
doc.save(docx_tmp)
|
| 662 |
+
progress(0.8, desc="Converting to PDF")
|
| 663 |
+
rendered = DX.convert_to_pdf(docx_tmp)
|
| 664 |
+
if rendered:
|
| 665 |
+
want, got = DX.count_docx_pictures(doc), DX.count_pdf_images(rendered)
|
| 666 |
+
if got < want:
|
| 667 |
+
warn = (f"{want - got} of {want} pictures from the Word file did not render in the PDF. "
|
| 668 |
+
"For a report identical to Word, save the manuscript as PDF from Word "
|
| 669 |
+
"and upload that PDF instead.")
|
| 670 |
+
progress(0.9, desc="Building report")
|
| 671 |
+
build_overlay_pdf(rendered, [], stats, fname, size_kb, hi, mod, pdf)
|
| 672 |
+
else:
|
| 673 |
+
warn = ("Word-to-PDF conversion failed, so this report is re-typeset text without "
|
| 674 |
+
"figures/equations. Reason: " + (DX.LAST_ERROR or "unknown") +
|
| 675 |
+
" — Workaround: save the manuscript as PDF from Word and upload the PDF.")
|
| 676 |
+
build_pdf(blocks, stats, fname, size_kb, hi, mod, pdf)
|
| 677 |
+
elif src_pdf: # ---- PDF: annotate original pages
|
| 678 |
+
progress(0.85, desc="Building report")
|
| 679 |
+
build_overlay_pdf(src_pdf, blocks, stats, fname, size_kb, hi, mod, pdf)
|
| 680 |
+
else: # ---- plain text
|
| 681 |
+
progress(0.85, desc="Building report")
|
| 682 |
+
build_pdf(blocks, stats, fname, size_kb, hi, mod, pdf)
|
| 683 |
+
|
| 684 |
+
page = build_html(blocks, stats, hi, mod)
|
| 685 |
+
if warn:
|
| 686 |
+
page = (f"<div style='font-family:system-ui;background:#fff4e5;border:1px solid #f0b458;"
|
| 687 |
+
f"color:#6b4400;padding:10px 14px;border-radius:8px;margin-bottom:10px'>⚠ {html.escape(warn)}</div>"
|
| 688 |
+
+ page)
|
| 689 |
+
return page, pdf
|
docx_report.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Word (.docx) pipeline for the AI Writing Report Space.
|
| 3 |
+
|
| 4 |
+
1. Clean: remove empty numbered paragraphs (Word hides them, LibreOffice prints them).
|
| 5 |
+
2. Read: paragraphs in document order, keeping a reference to each paragraph's XML.
|
| 6 |
+
3. Inject: after scoring, colour each sentence directly in the original .docx
|
| 7 |
+
(run-level shading) -> the Word file keeps 100 % of its structure.
|
| 8 |
+
4. Render: convert the highlighted .docx to PDF with the newest LibreOffice available.
|
| 9 |
+
5. Check: compare pictures in the .docx with images in the PDF and warn if any are lost.
|
| 10 |
+
"""
|
| 11 |
+
import os, re, copy, glob, shutil, tarfile, tempfile, threading, subprocess, urllib.request
|
| 12 |
+
|
| 13 |
+
from docx import Document
|
| 14 |
+
from docx.oxml.ns import qn
|
| 15 |
+
|
| 16 |
+
W_R, W_T = qn("w:r"), qn("w:t")
|
| 17 |
+
SKIP_ANCESTORS = {qn("w:txbxContent"), qn("w:del"), qn("w:moveFrom"),
|
| 18 |
+
"{http://schemas.openxmlformats.org/markup-compatibility/2006}Fallback"}
|
| 19 |
+
|
| 20 |
+
# ============================================================ LibreOffice
|
| 21 |
+
LO_HOME = os.path.expanduser(os.getenv("LO_DIR", "~/lo-fresh"))
|
| 22 |
+
_lo_exe = None
|
| 23 |
+
_lo_ready = threading.Event()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _lo_version(exe):
|
| 27 |
+
try:
|
| 28 |
+
out = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=60).stdout
|
| 29 |
+
m = re.search(r"(\d+)\.(\d+)", out)
|
| 30 |
+
return (int(m.group(1)), int(m.group(2))) if m else (0, 0)
|
| 31 |
+
except Exception:
|
| 32 |
+
return (0, 0)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _fetch_fresh_lo():
|
| 36 |
+
"""Download the current stable LibreOffice .deb bundle from TDF and unpack it
|
| 37 |
+
without root (dpkg-deb -x). Newer releases import Word layouts far better than
|
| 38 |
+
the Debian package (floating images, wrapping, grouped shapes, SmartArt)."""
|
| 39 |
+
found = glob.glob(os.path.join(LO_HOME, "opt", "libreoffice*", "program", "soffice"))
|
| 40 |
+
if found:
|
| 41 |
+
return found[0]
|
| 42 |
+
base = "https://download.documentfoundation.org/libreoffice/stable/"
|
| 43 |
+
idx = urllib.request.urlopen(base, timeout=60).read().decode()
|
| 44 |
+
vers = sorted(set(re.findall(r'href="(\d+\.\d+\.\d+)/"', idx)),
|
| 45 |
+
key=lambda v: tuple(map(int, v.split("."))))
|
| 46 |
+
if not vers:
|
| 47 |
+
return None
|
| 48 |
+
v = vers[-1]
|
| 49 |
+
url = f"{base}{v}/deb/x86_64/LibreOffice_{v}_Linux_x86-64_deb.tar.gz"
|
| 50 |
+
tmp = tempfile.mkdtemp()
|
| 51 |
+
tgz = os.path.join(tmp, "lo.tgz")
|
| 52 |
+
with urllib.request.urlopen(url, timeout=600) as r, open(tgz, "wb") as f:
|
| 53 |
+
shutil.copyfileobj(r, f)
|
| 54 |
+
with tarfile.open(tgz) as t:
|
| 55 |
+
t.extractall(tmp)
|
| 56 |
+
os.makedirs(LO_HOME, exist_ok=True)
|
| 57 |
+
for deb in glob.glob(os.path.join(tmp, "*", "DEBS", "*.deb")):
|
| 58 |
+
subprocess.run(["dpkg-deb", "-x", deb, LO_HOME], check=True, capture_output=True)
|
| 59 |
+
shutil.rmtree(tmp, ignore_errors=True)
|
| 60 |
+
found = glob.glob(os.path.join(LO_HOME, "opt", "libreoffice*", "program", "soffice"))
|
| 61 |
+
return found[0] if found else None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _init_lo():
|
| 65 |
+
global _lo_exe
|
| 66 |
+
system = shutil.which("soffice") or shutil.which("libreoffice")
|
| 67 |
+
best, best_v = system, _lo_version(system) if system else (0, 0)
|
| 68 |
+
if os.getenv("LO_AUTO_UPDATE", "0") == "1":
|
| 69 |
+
try:
|
| 70 |
+
fresh = _fetch_fresh_lo()
|
| 71 |
+
fv = _lo_version(fresh) if fresh else (0, 0)
|
| 72 |
+
if fv > best_v:
|
| 73 |
+
best, best_v = fresh, fv
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"[docx] fresh LibreOffice unavailable ({e}); using system build", flush=True)
|
| 76 |
+
_lo_exe = best
|
| 77 |
+
print(f"[docx] LibreOffice for rendering: {best} version {best_v}", flush=True)
|
| 78 |
+
_lo_ready.set()
|
| 79 |
+
self_test()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def start_lo_init():
|
| 83 |
+
threading.Thread(target=_init_lo, daemon=True).start()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def lo_exe(wait=300):
|
| 87 |
+
_lo_ready.wait(wait)
|
| 88 |
+
return _lo_exe or shutil.which("soffice") or shutil.which("libreoffice")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
LAST_ERROR = ""
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def convert_to_pdf(docx_path):
|
| 95 |
+
"""DOCX -> PDF. Two attempts with a private LibreOffice profile; the reason for any
|
| 96 |
+
failure is kept in LAST_ERROR so the app can show it."""
|
| 97 |
+
global LAST_ERROR
|
| 98 |
+
LAST_ERROR = ""
|
| 99 |
+
exe = lo_exe()
|
| 100 |
+
if not exe:
|
| 101 |
+
LAST_ERROR = "LibreOffice is not installed on the Space (check packages.txt and the build log)."
|
| 102 |
+
print("[docx] " + LAST_ERROR, flush=True)
|
| 103 |
+
return None
|
| 104 |
+
for attempt, target in enumerate(("pdf:writer_pdf_Export", "pdf")):
|
| 105 |
+
out = tempfile.mkdtemp()
|
| 106 |
+
profile = tempfile.mkdtemp()
|
| 107 |
+
cmd = [exe, f"-env:UserInstallation=file://{profile}", "--headless", "--invisible",
|
| 108 |
+
"--nodefault", "--nologo", "--norestore", "--nolockcheck",
|
| 109 |
+
"--convert-to", target, "--outdir", out, docx_path]
|
| 110 |
+
try:
|
| 111 |
+
r = subprocess.run(cmd, timeout=420, capture_output=True, text=True,
|
| 112 |
+
env={**os.environ, "HOME": profile, "SAL_USE_VCLPLUGIN": "svp"})
|
| 113 |
+
pdf = os.path.join(out, os.path.splitext(os.path.basename(docx_path))[0] + ".pdf")
|
| 114 |
+
if os.path.exists(pdf) and os.path.getsize(pdf) > 0:
|
| 115 |
+
return pdf
|
| 116 |
+
LAST_ERROR = (f"LibreOffice exit {r.returncode}: " + (r.stderr or r.stdout or "")[-600:]).strip()
|
| 117 |
+
except subprocess.TimeoutExpired:
|
| 118 |
+
LAST_ERROR = "LibreOffice timed out while converting the document."
|
| 119 |
+
except Exception as e:
|
| 120 |
+
LAST_ERROR = f"LibreOffice could not run: {e}"
|
| 121 |
+
print(f"[docx] conversion attempt {attempt + 1} failed: {LAST_ERROR}", flush=True)
|
| 122 |
+
return None
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def self_test():
|
| 126 |
+
"""Convert a one-line document at startup and log whether rendering works."""
|
| 127 |
+
try:
|
| 128 |
+
d = Document()
|
| 129 |
+
d.add_paragraph("LibreOffice self-test.")
|
| 130 |
+
p = os.path.join(tempfile.mkdtemp(), "selftest.docx")
|
| 131 |
+
d.save(p)
|
| 132 |
+
ok = convert_to_pdf(p)
|
| 133 |
+
print(f"[docx] self-test: {'OK' if ok else 'FAILED - ' + LAST_ERROR}", flush=True)
|
| 134 |
+
except Exception as e:
|
| 135 |
+
print(f"[docx] self-test error: {e}", flush=True)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ============================================================ reading
|
| 139 |
+
def _skip_run(r):
|
| 140 |
+
a = r.getparent()
|
| 141 |
+
while a is not None:
|
| 142 |
+
if a.tag in SKIP_ANCESTORS:
|
| 143 |
+
return True
|
| 144 |
+
a = a.getparent()
|
| 145 |
+
return False
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def para_runs(p_el):
|
| 149 |
+
"""Visible w:r elements of a paragraph, in order (hyperlinks/fields included,
|
| 150 |
+
text boxes / deleted text excluded)."""
|
| 151 |
+
return [r for r in p_el.iter(W_R) if not _skip_run(r)]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def run_text(r):
|
| 155 |
+
out = []
|
| 156 |
+
for c in r:
|
| 157 |
+
tag = c.tag
|
| 158 |
+
if tag == W_T:
|
| 159 |
+
out.append(c.text or "")
|
| 160 |
+
elif tag == qn("w:tab"):
|
| 161 |
+
out.append("\t")
|
| 162 |
+
elif tag in (qn("w:br"), qn("w:cr")):
|
| 163 |
+
out.append("\n")
|
| 164 |
+
elif tag == qn("w:noBreakHyphen"):
|
| 165 |
+
out.append("-")
|
| 166 |
+
return "".join(out)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def para_text(p_el):
|
| 170 |
+
return "".join(run_text(r) for r in para_runs(p_el))
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _numbered(p, doc):
|
| 174 |
+
pPr = p._p.pPr
|
| 175 |
+
if pPr is not None and pPr.find(qn("w:numPr")) is not None:
|
| 176 |
+
return True
|
| 177 |
+
st = p.style
|
| 178 |
+
while st is not None:
|
| 179 |
+
sp = st.element.find(qn("w:pPr"))
|
| 180 |
+
if sp is not None and sp.find(qn("w:numPr")) is not None:
|
| 181 |
+
return True
|
| 182 |
+
st = st.base_style
|
| 183 |
+
return False
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _empty(p):
|
| 187 |
+
if para_text(p._p).strip():
|
| 188 |
+
return False
|
| 189 |
+
for tag in ("w:drawing", "w:pict", "w:object"):
|
| 190 |
+
if p._p.findall(".//" + qn(tag)):
|
| 191 |
+
return False
|
| 192 |
+
if p._p.xpath(".//*[local-name()='oMath' or local-name()='oMathPara']"):
|
| 193 |
+
return False
|
| 194 |
+
return True
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _neutralise(p):
|
| 198 |
+
"""Keep an empty numbered paragraph (it advances the list counter, so later heading
|
| 199 |
+
numbers stay correct) but make it invisible: white 1-pt label, zero line height."""
|
| 200 |
+
from docx.oxml import parse_xml
|
| 201 |
+
W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
|
| 202 |
+
pPr = p._p.get_or_add_pPr()
|
| 203 |
+
for old in pPr.findall(qn("w:spacing")) + pPr.findall(qn("w:rPr")):
|
| 204 |
+
pPr.remove(old)
|
| 205 |
+
sp = parse_xml(f'<w:spacing {W} w:before="0" w:after="0" w:line="20" w:lineRule="exact"/>')
|
| 206 |
+
anchor = next((c for c in pPr if c.tag in {qn("w:ind"), qn("w:contextualSpacing"), qn("w:jc"),
|
| 207 |
+
qn("w:outlineLvl"), qn("w:sectPr"), qn("w:pPrChange")}), None)
|
| 208 |
+
if anchor is not None:
|
| 209 |
+
anchor.addprevious(sp)
|
| 210 |
+
else:
|
| 211 |
+
pPr.append(sp)
|
| 212 |
+
rpr = parse_xml(f'<w:rPr {W}><w:color w:val="FFFFFF"/><w:sz w:val="2"/><w:szCs w:val="2"/></w:rPr>')
|
| 213 |
+
tail = next((c for c in pPr if c.tag in {qn("w:sectPr"), qn("w:pPrChange")}), None)
|
| 214 |
+
if tail is not None:
|
| 215 |
+
tail.addprevious(rpr)
|
| 216 |
+
else:
|
| 217 |
+
pPr.append(rpr)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
OUR_FILLS = {"A9E4EE", "E4DCF5"}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def open_clean(path):
|
| 224 |
+
"""Open the .docx, hide empty numbered paragraphs (without breaking numbering) and
|
| 225 |
+
remove highlight shading left over from a previous report."""
|
| 226 |
+
doc = Document(path)
|
| 227 |
+
paras = list(doc.paragraphs)
|
| 228 |
+
for t in doc.tables:
|
| 229 |
+
for row in t.rows:
|
| 230 |
+
for cell in row.cells:
|
| 231 |
+
paras += cell.paragraphs
|
| 232 |
+
for p in paras:
|
| 233 |
+
try:
|
| 234 |
+
if _empty(p) and _numbered(p, doc):
|
| 235 |
+
_neutralise(p)
|
| 236 |
+
except Exception:
|
| 237 |
+
pass
|
| 238 |
+
for shd in doc.element.body.iter(qn("w:shd")):
|
| 239 |
+
if (shd.get(qn("w:fill")) or "").upper() in OUR_FILLS and shd.getparent().tag == qn("w:rPr"):
|
| 240 |
+
shd.getparent().remove(shd)
|
| 241 |
+
return doc
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def iter_body(doc):
|
| 245 |
+
"""Yields ('p', Paragraph) or ('tbl', Table) in body order."""
|
| 246 |
+
from docx.text.paragraph import Paragraph
|
| 247 |
+
from docx.table import Table
|
| 248 |
+
for child in doc.element.body.iterchildren():
|
| 249 |
+
tag = child.tag.split("}")[-1]
|
| 250 |
+
if tag == "p":
|
| 251 |
+
yield "p", Paragraph(child, doc)
|
| 252 |
+
elif tag == "tbl":
|
| 253 |
+
yield "tbl", Table(child, doc)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
# ============================================================ injection
|
| 257 |
+
_RPR_AFTER_SHD = {qn(t) for t in ("w:fitText", "w:vertAlign", "w:rtl", "w:cs", "w:em", "w:lang",
|
| 258 |
+
"w:eastAsianLayout", "w:specVanish", "w:oMath")}
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def _shade(r, fill):
|
| 262 |
+
rPr = r.find(qn("w:rPr"))
|
| 263 |
+
if rPr is None:
|
| 264 |
+
rPr = r.makeelement(qn("w:rPr"), {})
|
| 265 |
+
r.insert(0, rPr)
|
| 266 |
+
for old in rPr.findall(qn("w:shd")) + rPr.findall(qn("w:highlight")):
|
| 267 |
+
rPr.remove(old)
|
| 268 |
+
shd = rPr.makeelement(qn("w:shd"), {qn("w:val"): "clear", qn("w:color"): "auto", qn("w:fill"): fill})
|
| 269 |
+
pos = next((i for i, c in enumerate(rPr) if c.tag in _RPR_AFTER_SHD), len(rPr))
|
| 270 |
+
rPr.insert(pos, shd)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def _simple(r):
|
| 274 |
+
kids = [c for c in r if c.tag != qn("w:rPr")]
|
| 275 |
+
return len(kids) == 1 and kids[0].tag == W_T
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _split_simple(r, cuts):
|
| 279 |
+
"""Split a single-text run at relative offsets; returns the new runs in order."""
|
| 280 |
+
t = r.find(W_T).text or ""
|
| 281 |
+
pts = [0] + sorted(c for c in set(cuts) if 0 < c < len(t)) + [len(t)]
|
| 282 |
+
if len(pts) == 2:
|
| 283 |
+
return [r]
|
| 284 |
+
parent, idx = r.getparent(), r.getparent().index(r)
|
| 285 |
+
new = []
|
| 286 |
+
for a, b in zip(pts, pts[1:]):
|
| 287 |
+
nr = copy.deepcopy(r)
|
| 288 |
+
te = nr.find(W_T)
|
| 289 |
+
te.text = t[a:b]
|
| 290 |
+
te.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
| 291 |
+
new.append(nr)
|
| 292 |
+
parent.remove(r)
|
| 293 |
+
for i, nr in enumerate(new):
|
| 294 |
+
parent.insert(idx + i, nr)
|
| 295 |
+
return new
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def colour_paragraph(p_el, spans):
|
| 299 |
+
"""spans: list of (start, end, hex_fill) character ranges on para_text(p_el)."""
|
| 300 |
+
if not spans:
|
| 301 |
+
return
|
| 302 |
+
cuts = sorted({x for a, b, _ in spans for x in (a, b)})
|
| 303 |
+
pos = 0
|
| 304 |
+
pieces = [] # (run, start, end)
|
| 305 |
+
for r in para_runs(p_el):
|
| 306 |
+
t = run_text(r)
|
| 307 |
+
s, e = pos, pos + len(t)
|
| 308 |
+
pos = e
|
| 309 |
+
if not t:
|
| 310 |
+
continue
|
| 311 |
+
if _simple(r):
|
| 312 |
+
rel = [c - s for c in cuts if s < c < e]
|
| 313 |
+
off = s
|
| 314 |
+
for nr in _split_simple(r, rel):
|
| 315 |
+
ln = len(nr.find(W_T).text or "")
|
| 316 |
+
pieces.append((nr, off, off + ln))
|
| 317 |
+
off += ln
|
| 318 |
+
else:
|
| 319 |
+
pieces.append((r, s, e))
|
| 320 |
+
for r, s, e in pieces:
|
| 321 |
+
mid = (s + e) / 2
|
| 322 |
+
for a, b, fill in spans:
|
| 323 |
+
if a <= mid < b:
|
| 324 |
+
_shade(r, fill)
|
| 325 |
+
break
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def sentence_char_spans(raw, sentences):
|
| 329 |
+
"""Locate each sentence (a stripped substring of raw) in order."""
|
| 330 |
+
out, cur = [], 0
|
| 331 |
+
for s in sentences:
|
| 332 |
+
i = raw.find(s, cur)
|
| 333 |
+
if i < 0: # whitespace differences -> fuzzy fallback
|
| 334 |
+
first = s.split()[0] if s.split() else s
|
| 335 |
+
i = raw.find(first, cur)
|
| 336 |
+
if i < 0:
|
| 337 |
+
out.append(None)
|
| 338 |
+
continue
|
| 339 |
+
j = i + len(s)
|
| 340 |
+
else:
|
| 341 |
+
j = i + len(s)
|
| 342 |
+
out.append((i, j))
|
| 343 |
+
cur = j
|
| 344 |
+
return out
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# ============================================================ figure check
|
| 348 |
+
def count_docx_pictures(doc):
|
| 349 |
+
body = doc.element.body
|
| 350 |
+
return len(body.xpath(".//*[local-name()='blip']")) + len(body.xpath(".//*[local-name()='imagedata']"))
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def count_pdf_images(pdf_path):
|
| 354 |
+
import pymupdf
|
| 355 |
+
seen = set()
|
| 356 |
+
with pymupdf.open(pdf_path) as d:
|
| 357 |
+
for p in d:
|
| 358 |
+
for img in p.get_images(full=True):
|
| 359 |
+
seen.add(img[0])
|
| 360 |
+
return len(seen)
|
favicon.png
ADDED
|
|
humanizer.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Humanizer engine (Parrot T5 paraphraser) + stylometric indicators + DOCX helpers."""
|
| 2 |
+
import base64
|
| 3 |
+
import hashlib
|
| 4 |
+
import hmac
|
| 5 |
+
import secrets
|
| 6 |
+
import sqlite3
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import statistics
|
| 10 |
+
import tempfile
|
| 11 |
+
import threading
|
| 12 |
+
import time
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import gradio as gr
|
| 16 |
+
import torch
|
| 17 |
+
|
| 18 |
+
try: # ZeroGPU (HF Pro); falls back to plain CPU/GPU hardware
|
| 19 |
+
import spaces
|
| 20 |
+
_GPU = spaces.GPU
|
| 21 |
+
except Exception: # pragma: no cover
|
| 22 |
+
spaces = None
|
| 23 |
+
def _GPU(duration=None):
|
| 24 |
+
return lambda fn: fn
|
| 25 |
+
from docx import Document
|
| 26 |
+
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Brand (colours sampled from the CS Software Solutions logo)
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
BRAND = {
|
| 32 |
+
"navy": "#07228B",
|
| 33 |
+
"purple": "#47148B",
|
| 34 |
+
"magenta": "#E92E7F",
|
| 35 |
+
"red": "#E12B41",
|
| 36 |
+
"orange": "#FE5828",
|
| 37 |
+
"yellow": "#F5C518",
|
| 38 |
+
"green": "#50BF49",
|
| 39 |
+
"sky": "#50B4EF",
|
| 40 |
+
"grey": "#4A4A4A",
|
| 41 |
+
}
|
| 42 |
+
RAINBOW = (
|
| 43 |
+
f"linear-gradient(90deg, {BRAND['purple']}, {BRAND['navy']}, {BRAND['sky']}, "
|
| 44 |
+
f"{BRAND['green']}, {BRAND['yellow']}, {BRAND['orange']}, {BRAND['red']}, {BRAND['magenta']})"
|
| 45 |
+
)
|
| 46 |
+
APP_NAME = "CS Academic Paraphraser"
|
| 47 |
+
SITE_URL = "https://cssoftwaresolutions.in"
|
| 48 |
+
|
| 49 |
+
LOGO_PATH = Path(__file__).parent / "logo.png"
|
| 50 |
+
LOGO_SRC = ""
|
| 51 |
+
FAVICON_PATH = Path(__file__).parent / "favicon.png"
|
| 52 |
+
FAVICON_SRC = ""
|
| 53 |
+
if FAVICON_PATH.exists():
|
| 54 |
+
FAVICON_SRC = "data:image/png;base64," + base64.b64encode(FAVICON_PATH.read_bytes()).decode()
|
| 55 |
+
if LOGO_PATH.exists():
|
| 56 |
+
LOGO_SRC = "data:image/png;base64," + base64.b64encode(LOGO_PATH.read_bytes()).decode()
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# Model (kept in CPU RAM while idle; moved to GPU only inside @spaces.GPU)
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
MODEL_ID = os.getenv("PARROT_MODEL", "prithivida/parrot_paraphraser_on_T5")
|
| 62 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
|
| 63 |
+
model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).eval()
|
| 64 |
+
model_lock = threading.Lock()
|
| 65 |
+
|
| 66 |
+
GPU_CHUNK = 48 # sentences per ZeroGPU allocation (short reservations)
|
| 67 |
+
CPU_CHUNK = 16 # sentences per CPU step (keeps progress updating)
|
| 68 |
+
OUT_DIR = Path(tempfile.gettempdir()) / "cs_outputs"
|
| 69 |
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 70 |
+
MODES = ["Auto (GPU, falls back to CPU)", "CPU only (no GPU quota)"]
|
| 71 |
+
MAX_PASTE_WORDS = int(os.getenv("MAX_PASTE_WORDS", "6000")) # keeps a single paste request within quota
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ---------------------------------------------------------------------------
|
| 75 |
+
# Text helpers
|
| 76 |
+
# ---------------------------------------------------------------------------
|
| 77 |
+
def split_sentences(text):
|
| 78 |
+
parts = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9\"'(\[])", text.strip())
|
| 79 |
+
return [p.strip() for p in parts if p.strip()]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def should_process(text, min_words):
|
| 83 |
+
text = (text or "").strip()
|
| 84 |
+
if len(text.split()) < int(min_words):
|
| 85 |
+
return False
|
| 86 |
+
if re.fullmatch(r"https?://\S+|www\.\S+|\S+@\S+\.\S+", text):
|
| 87 |
+
return False
|
| 88 |
+
alpha = sum(ch.isalpha() for ch in text)
|
| 89 |
+
if alpha < max(5, int(len(text) * 0.35)):
|
| 90 |
+
return False
|
| 91 |
+
return True
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def words(text):
|
| 95 |
+
return re.findall(r"[A-Za-z0-9']+", (text or "").lower())
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# ---------------------------------------------------------------------------
|
| 99 |
+
# Tentative indicators (local heuristics — NOT Turnitin / iThenticate / GPTZero)
|
| 100 |
+
# ---------------------------------------------------------------------------
|
| 101 |
+
def _ngrams(tokens, n):
|
| 102 |
+
return {tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def similarity_to_source(original, revised):
|
| 106 |
+
"""Share of the revised text's word n-grams that also occur in the original."""
|
| 107 |
+
a, b = words(original), words(revised)
|
| 108 |
+
if len(b) < 4 or len(a) < 4:
|
| 109 |
+
return 0.0
|
| 110 |
+
scores, weights = [], []
|
| 111 |
+
for n, w in ((2, 0.2), (3, 0.35), (4, 0.45)):
|
| 112 |
+
gb = _ngrams(b, n)
|
| 113 |
+
if not gb:
|
| 114 |
+
continue
|
| 115 |
+
scores.append(len(gb & _ngrams(a, n)) / len(gb))
|
| 116 |
+
weights.append(w)
|
| 117 |
+
if not weights:
|
| 118 |
+
return 0.0
|
| 119 |
+
return 100.0 * sum(s * w for s, w in zip(scores, weights)) / sum(weights)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
AI_MARKERS = [
|
| 123 |
+
"delve", "delves", "delving", "furthermore", "moreover", "additionally",
|
| 124 |
+
"in conclusion", "it is important to note", "it is worth noting", "plays a crucial role",
|
| 125 |
+
"plays a pivotal role", "pivotal", "crucial", "comprehensive", "leverage", "leveraging",
|
| 126 |
+
"seamless", "seamlessly", "robust", "landscape", "underscore", "underscores",
|
| 127 |
+
"notably", "in today's", "fostering", "foster", "multifaceted", "intricate",
|
| 128 |
+
"realm", "tapestry", "navigate", "navigating", "holistic", "paramount",
|
| 129 |
+
"a testament to", "shed light", "sheds light", "ever-evolving", "cutting-edge",
|
| 130 |
+
"in the realm of", "overall,", "ultimately", "enhance", "enhancing", "streamline",
|
| 131 |
+
"valuable insights", "key role", "vital role", "significantly",
|
| 132 |
+
]
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def ai_likelihood(text):
|
| 136 |
+
"""Stylometric estimate (0–100) of machine-like writing."""
|
| 137 |
+
toks = words(text)
|
| 138 |
+
if len(toks) < 30:
|
| 139 |
+
return None
|
| 140 |
+
sents = split_sentences(text) or [text]
|
| 141 |
+
lengths = [len(words(s)) for s in sents if words(s)]
|
| 142 |
+
|
| 143 |
+
# 1. Burstiness: humans vary sentence length more than LLMs.
|
| 144 |
+
if len(lengths) >= 3:
|
| 145 |
+
cv = statistics.pstdev(lengths) / max(1.0, statistics.mean(lengths))
|
| 146 |
+
burst = min(1.0, max(0.0, (0.60 - cv) / 0.45))
|
| 147 |
+
else:
|
| 148 |
+
burst = 0.5
|
| 149 |
+
|
| 150 |
+
# 2. Density of stock "LLM" vocabulary per 100 words.
|
| 151 |
+
low = " " + " ".join(toks) + " "
|
| 152 |
+
raw = (text or "").lower()
|
| 153 |
+
hits = sum(raw.count(m) if " " in m or "," in m else low.count(f" {m} ") for m in AI_MARKERS)
|
| 154 |
+
marker = min(1.0, (hits / len(toks) * 100) / 2.5)
|
| 155 |
+
|
| 156 |
+
# 3. Uniformity: mid-length sentences, repeated openers, low lexical variety.
|
| 157 |
+
mean_len = statistics.mean(lengths)
|
| 158 |
+
band = 1.0 if 16 <= mean_len <= 28 else 0.4
|
| 159 |
+
openers = [words(s)[0] for s in sents if words(s)]
|
| 160 |
+
repeat = 1 - len(set(openers)) / len(openers) if openers else 0
|
| 161 |
+
window = toks[:400]
|
| 162 |
+
ttr = len(set(window)) / len(window)
|
| 163 |
+
variety = min(1.0, max(0.0, (0.62 - ttr) / 0.25))
|
| 164 |
+
uniform = 0.45 * band + 0.3 * min(1.0, repeat * 2) + 0.25 * variety
|
| 165 |
+
|
| 166 |
+
score = 100 * (0.40 * burst + 0.35 * marker + 0.25 * uniform)
|
| 167 |
+
return float(max(3.0, min(97.0, score)))
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def words_changed(original, revised):
|
| 171 |
+
a, b = words(original), words(revised)
|
| 172 |
+
if not b:
|
| 173 |
+
return 0.0
|
| 174 |
+
return 100.0 * (1 - len(set(b) & set(a)) / len(set(b)))
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _level(value, invert=False):
|
| 178 |
+
if value is None:
|
| 179 |
+
return "#9aa0a6", "n/a"
|
| 180 |
+
v = 100 - value if invert else value
|
| 181 |
+
if v < 30:
|
| 182 |
+
return BRAND["green"], "Low"
|
| 183 |
+
if v < 55:
|
| 184 |
+
return BRAND["orange"], "Moderate"
|
| 185 |
+
return BRAND["red"], "High"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _gauge(title, value, caption, invert=False, fixed_color=None):
|
| 189 |
+
color, label = _level(value, invert)
|
| 190 |
+
if fixed_color:
|
| 191 |
+
color = fixed_color
|
| 192 |
+
label = "Rewrite depth"
|
| 193 |
+
pct = 0 if value is None else round(value)
|
| 194 |
+
shown = "—" if value is None else f"{pct}%"
|
| 195 |
+
return f"""
|
| 196 |
+
<div class="cs-gauge">
|
| 197 |
+
<div class="cs-ring" style="--p:{pct};--c:{color}">
|
| 198 |
+
<div class="cs-ring-inner"><span>{shown}</span></div>
|
| 199 |
+
</div>
|
| 200 |
+
<div class="cs-gauge-title">{title}</div>
|
| 201 |
+
<div class="cs-chip" style="background:{color}1f;color:{color}">{label}</div>
|
| 202 |
+
<div class="cs-gauge-cap">{caption}</div>
|
| 203 |
+
</div>"""
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def score_panel(original, revised):
|
| 207 |
+
sim = similarity_to_source(original, revised)
|
| 208 |
+
ai_in = ai_likelihood(original)
|
| 209 |
+
ai_out = ai_likelihood(revised)
|
| 210 |
+
changed = words_changed(original, revised)
|
| 211 |
+
return f"""
|
| 212 |
+
<div class="cs-scores">
|
| 213 |
+
{_gauge("Similarity to source", sim, "Word n-gram overlap with your original")}
|
| 214 |
+
{_gauge("AI indicator · input", ai_in, "Stylometric estimate of the original")}
|
| 215 |
+
{_gauge("AI indicator · output", ai_out, "Stylometric estimate after paraphrasing")}
|
| 216 |
+
{_gauge("Vocabulary changed", changed, "Share of new wording", fixed_color=BRAND["navy"])}
|
| 217 |
+
</div>
|
| 218 |
+
<div class="cs-disclaimer">
|
| 219 |
+
<b>Tentative indicators only.</b> Similarity is measured against your own original text,
|
| 220 |
+
not against internet or journal databases, and the AI indicator is a stylometric heuristic.
|
| 221 |
+
Results will differ from Turnitin, iThenticate or other detectors. Always review the
|
| 222 |
+
output for meaning, technical terms and citations before use.
|
| 223 |
+
</div>"""
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
EMPTY_SCORES = """
|
| 227 |
+
<div class="cs-scores cs-empty">
|
| 228 |
+
<div>Scores appear here after paraphrasing — similarity to source, AI indicators and rewrite depth.</div>
|
| 229 |
+
</div>"""
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# ---------------------------------------------------------------------------
|
| 233 |
+
# DOCX structure helpers
|
| 234 |
+
# ---------------------------------------------------------------------------
|
| 235 |
+
def iter_table_paragraphs(table):
|
| 236 |
+
for row in table.rows:
|
| 237 |
+
for cell in row.cells:
|
| 238 |
+
for paragraph in cell.paragraphs:
|
| 239 |
+
yield paragraph
|
| 240 |
+
for nested_table in cell.tables:
|
| 241 |
+
yield from iter_table_paragraphs(nested_table)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def collect_paragraphs(doc):
|
| 245 |
+
seen = set()
|
| 246 |
+
|
| 247 |
+
def unique(paragraphs):
|
| 248 |
+
for paragraph in paragraphs:
|
| 249 |
+
key = id(paragraph._p)
|
| 250 |
+
if key not in seen:
|
| 251 |
+
seen.add(key)
|
| 252 |
+
yield paragraph
|
| 253 |
+
|
| 254 |
+
yield from unique(doc.paragraphs)
|
| 255 |
+
for table in doc.tables:
|
| 256 |
+
yield from unique(iter_table_paragraphs(table))
|
| 257 |
+
for section in doc.sections:
|
| 258 |
+
for part in (
|
| 259 |
+
section.header, section.footer,
|
| 260 |
+
section.first_page_header, section.first_page_footer,
|
| 261 |
+
section.even_page_header, section.even_page_footer,
|
| 262 |
+
):
|
| 263 |
+
yield from unique(part.paragraphs)
|
| 264 |
+
for table in part.tables:
|
| 265 |
+
yield from unique(iter_table_paragraphs(table))
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def _is_protected(run):
|
| 269 |
+
return bool(
|
| 270 |
+
run._r.xpath(".//w:drawing")
|
| 271 |
+
or run._r.xpath(".//w:object")
|
| 272 |
+
or run._r.xpath(".//w:fldChar")
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def replace_text_preserving_paragraph(paragraph, new_text):
|
| 277 |
+
runs = list(paragraph.runs)
|
| 278 |
+
if not runs:
|
| 279 |
+
paragraph.add_run(new_text)
|
| 280 |
+
return
|
| 281 |
+
target = next((r for r in runs if not _is_protected(r)), None)
|
| 282 |
+
if target is None:
|
| 283 |
+
target = paragraph.add_run()
|
| 284 |
+
target.text = new_text
|
| 285 |
+
for run in runs:
|
| 286 |
+
if run is not target and not _is_protected(run):
|
| 287 |
+
run.text = ""
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# ---------------------------------------------------------------------------
|
| 291 |
+
# Paraphrasing
|
| 292 |
+
# ---------------------------------------------------------------------------
|
| 293 |
+
def _generate(texts, temperature, device, batch_size):
|
| 294 |
+
temperature = max(0.3, min(float(temperature), 1.5))
|
| 295 |
+
outputs = []
|
| 296 |
+
with torch.inference_mode():
|
| 297 |
+
for start in range(0, len(texts), batch_size):
|
| 298 |
+
chunk = texts[start:start + batch_size]
|
| 299 |
+
encoded = tokenizer(
|
| 300 |
+
["paraphrase: " + t for t in chunk],
|
| 301 |
+
return_tensors="pt", padding=True, truncation=True, max_length=256,
|
| 302 |
+
)
|
| 303 |
+
encoded = {k: v.to(device) for k, v in encoded.items()}
|
| 304 |
+
generated = model.generate(
|
| 305 |
+
**encoded,
|
| 306 |
+
do_sample=True,
|
| 307 |
+
temperature=temperature,
|
| 308 |
+
top_p=0.92,
|
| 309 |
+
num_return_sequences=1,
|
| 310 |
+
max_new_tokens=180,
|
| 311 |
+
no_repeat_ngram_size=3,
|
| 312 |
+
)
|
| 313 |
+
decoded = tokenizer.batch_decode(generated.detach().cpu(), skip_special_tokens=True)
|
| 314 |
+
outputs.extend(c.strip() if c.strip() else s for s, c in zip(chunk, decoded))
|
| 315 |
+
del encoded, generated
|
| 316 |
+
return outputs
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
def _gpu_duration(texts, temperature):
|
| 320 |
+
# Reserve only what this chunk needs, so visitors' daily ZeroGPU quota lasts longer.
|
| 321 |
+
return int(min(90, 12 + 0.3 * len(texts or [])))
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
@_GPU(duration=_gpu_duration)
|
| 325 |
+
def paraphrase_batch_gpu(texts, temperature):
|
| 326 |
+
if not texts:
|
| 327 |
+
return []
|
| 328 |
+
if not torch.cuda.is_available():
|
| 329 |
+
raise RuntimeError("GPU allocation did not expose CUDA.")
|
| 330 |
+
with model_lock:
|
| 331 |
+
model.to("cuda")
|
| 332 |
+
try:
|
| 333 |
+
return _generate(texts, temperature, "cuda", batch_size=16)
|
| 334 |
+
finally:
|
| 335 |
+
model.to("cpu")
|
| 336 |
+
torch.cuda.empty_cache()
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def paraphrase_batch_cpu(texts, temperature):
|
| 340 |
+
if not texts:
|
| 341 |
+
return []
|
| 342 |
+
with model_lock:
|
| 343 |
+
model.to("cpu")
|
| 344 |
+
return _generate(texts, temperature, "cpu", batch_size=4)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def _is_gpu_unavailable(err):
|
| 348 |
+
msg = str(err).lower()
|
| 349 |
+
return any(k in msg for k in ("quota", "zerogpu", "gpu task aborted", "cuda", "no gpu", "queue is full"))
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def paraphrase_paragraphs(texts, temperature, progress=None, lo=0.15, hi=0.85, mode="Auto"):
|
| 353 |
+
"""Returns (paragraphs, engine_note)."""
|
| 354 |
+
groups, flat = [], []
|
| 355 |
+
for text in texts:
|
| 356 |
+
sents = split_sentences(text) or [text]
|
| 357 |
+
start = len(flat)
|
| 358 |
+
flat.extend(sents)
|
| 359 |
+
groups.append((start, len(flat)))
|
| 360 |
+
|
| 361 |
+
use_gpu = not str(mode).lower().startswith("cpu")
|
| 362 |
+
fell_back = False
|
| 363 |
+
rewritten = []
|
| 364 |
+
total = max(1, len(flat))
|
| 365 |
+
step = GPU_CHUNK if use_gpu else CPU_CHUNK
|
| 366 |
+
i = 0
|
| 367 |
+
while i < len(flat):
|
| 368 |
+
chunk = flat[i:i + step]
|
| 369 |
+
engine = "GPU" if use_gpu else "CPU"
|
| 370 |
+
if progress is not None:
|
| 371 |
+
progress(lo + (hi - lo) * i / total,
|
| 372 |
+
desc=f"Paraphrasing sentences {i + 1}–{i + len(chunk)} of {total} ({engine})")
|
| 373 |
+
if use_gpu:
|
| 374 |
+
try:
|
| 375 |
+
rewritten.extend(paraphrase_batch_gpu(chunk, temperature))
|
| 376 |
+
except Exception as err:
|
| 377 |
+
if not _is_gpu_unavailable(err):
|
| 378 |
+
raise
|
| 379 |
+
use_gpu, fell_back, step = False, True, CPU_CHUNK
|
| 380 |
+
continue # retry the same chunk on CPU
|
| 381 |
+
else:
|
| 382 |
+
rewritten.extend(paraphrase_batch_cpu(chunk, temperature))
|
| 383 |
+
i += len(chunk)
|
| 384 |
+
|
| 385 |
+
if fell_back:
|
| 386 |
+
note = "⚠️ GPU quota was unavailable, so the remaining text was processed on CPU (slower, same quality)."
|
| 387 |
+
elif not str(mode).lower().startswith("cpu"):
|
| 388 |
+
note = "⚡ Processed on GPU."
|
| 389 |
+
else:
|
| 390 |
+
note = "🖥️ Processed on CPU."
|
| 391 |
+
return [" ".join(rewritten[s:e]) for s, e in groups], note
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def _preview(pairs, limit=10):
|
| 395 |
+
blocks = []
|
| 396 |
+
for i, (before, after) in enumerate(pairs[:limit], 1):
|
| 397 |
+
blocks.append(
|
| 398 |
+
f"#### Change {i}\n**Original:** {before}\n\n**Paraphrased:** {after}"
|
| 399 |
+
)
|
| 400 |
+
return "\n\n---\n\n".join(blocks) if blocks else "No textual changes were generated."
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
# ---------------------------------------------------------------------------
|
| 404 |
+
# Handlers
|
| 405 |
+
# ---------------------------------------------------------------------------
|
| 406 |
+
def _docx_core(file_path, temperature, min_words, mode, progress):
|
| 407 |
+
if not file_path:
|
| 408 |
+
raise gr.Error("Please upload a .docx file.")
|
| 409 |
+
source_path = Path(file_path)
|
| 410 |
+
if source_path.suffix.lower() != ".docx":
|
| 411 |
+
raise gr.Error("Only .docx Word documents are supported.")
|
| 412 |
+
|
| 413 |
+
progress(0.05, desc="Opening Word document")
|
| 414 |
+
doc = Document(str(source_path))
|
| 415 |
+
|
| 416 |
+
targets, originals = [], []
|
| 417 |
+
for paragraph in collect_paragraphs(doc):
|
| 418 |
+
text = paragraph.text
|
| 419 |
+
if should_process(text, min_words):
|
| 420 |
+
targets.append(paragraph)
|
| 421 |
+
originals.append(text)
|
| 422 |
+
|
| 423 |
+
if not originals:
|
| 424 |
+
raise gr.Error("No eligible prose paragraphs were found in the document.")
|
| 425 |
+
|
| 426 |
+
revised, engine_note = paraphrase_paragraphs(originals, temperature, progress, mode=mode)
|
| 427 |
+
|
| 428 |
+
progress(0.9, desc="Injecting revised text into the original layout")
|
| 429 |
+
changed, pairs, final_after = 0, [], []
|
| 430 |
+
for paragraph, before, after in zip(targets, originals, revised):
|
| 431 |
+
after = (after or "").strip()
|
| 432 |
+
if after and after != before.strip():
|
| 433 |
+
replace_text_preserving_paragraph(paragraph, after)
|
| 434 |
+
changed += 1
|
| 435 |
+
pairs.append((before, after))
|
| 436 |
+
final_after.append(after)
|
| 437 |
+
else:
|
| 438 |
+
final_after.append(before)
|
| 439 |
+
|
| 440 |
+
output_path = _save_output(doc, source_path.stem)
|
| 441 |
+
progress(1.0, desc="Completed")
|
| 442 |
+
|
| 443 |
+
summary = (
|
| 444 |
+
f"✅ **Completed** — {changed} of {len(originals)} eligible paragraphs rewritten. "
|
| 445 |
+
f"Layout, styles, tables, images and headers/footers were kept in place.\n\n{engine_note}"
|
| 446 |
+
)
|
| 447 |
+
scores = score_panel("\n".join(originals), "\n".join(final_after))
|
| 448 |
+
return output_path, download_button(output_path), summary, scores, _preview(pairs)
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _text_core(text, temperature, min_words, mode, progress):
|
| 452 |
+
text = (text or "").strip()
|
| 453 |
+
if not text:
|
| 454 |
+
raise gr.Error("Please paste some text to paraphrase.")
|
| 455 |
+
n_words = len(text.split())
|
| 456 |
+
if n_words > MAX_PASTE_WORDS:
|
| 457 |
+
raise gr.Error(
|
| 458 |
+
f"Pasted text has {n_words} words. Please keep it under {MAX_PASTE_WORDS} words, "
|
| 459 |
+
"or upload it as a .docx instead."
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
progress(0.05, desc="Reading pasted text")
|
| 463 |
+
paragraphs = [p.strip() for p in re.split(r"\n\s*\n|\r?\n", text)]
|
| 464 |
+
idx = [i for i, p in enumerate(paragraphs) if should_process(p, min_words)]
|
| 465 |
+
if not idx:
|
| 466 |
+
raise gr.Error(f"No paragraph has at least {int(min_words)} words of prose. Lower the minimum and try again.")
|
| 467 |
+
|
| 468 |
+
revised, engine_note = paraphrase_paragraphs([paragraphs[i] for i in idx], temperature, progress, mode=mode)
|
| 469 |
+
result = list(paragraphs)
|
| 470 |
+
pairs = []
|
| 471 |
+
for i, after in zip(idx, revised):
|
| 472 |
+
after = (after or "").strip()
|
| 473 |
+
if after and after != paragraphs[i]:
|
| 474 |
+
result[i] = after
|
| 475 |
+
pairs.append((paragraphs[i], after))
|
| 476 |
+
|
| 477 |
+
output_text = "\n\n".join(p for p in result if p)
|
| 478 |
+
|
| 479 |
+
progress(0.95, desc="Preparing download")
|
| 480 |
+
doc = Document()
|
| 481 |
+
for p in result:
|
| 482 |
+
if p:
|
| 483 |
+
doc.add_paragraph(p)
|
| 484 |
+
output_path = _save_output(doc, "pasted_text")
|
| 485 |
+
progress(1.0, desc="Completed")
|
| 486 |
+
|
| 487 |
+
summary = f"✅ **Completed** — {len(pairs)} of {len(idx)} eligible paragraphs rewritten ({n_words} words in).\n\n{engine_note}"
|
| 488 |
+
scores = score_panel(
|
| 489 |
+
"\n".join(paragraphs[i] for i in idx),
|
| 490 |
+
"\n".join(result[i] for i in idx),
|
| 491 |
+
)
|
| 492 |
+
return output_text, output_path, download_button(output_path), summary, scores
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
# Single branded progress panel (replaces Gradio's per-component loaders)
|
| 496 |
+
# ---------------------------------------------------------------------------
|
| 497 |
+
class _Reporter:
|
| 498 |
+
def __init__(self):
|
| 499 |
+
self.frac, self.desc, self.start = 0.0, "Starting…", time.time()
|
| 500 |
+
|
| 501 |
+
def __call__(self, frac, desc=None):
|
| 502 |
+
self.frac = max(self.frac, min(1.0, float(frac)))
|
| 503 |
+
if desc:
|
| 504 |
+
self.desc = desc
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
def _md2html(md):
|
| 508 |
+
html = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", md)
|
| 509 |
+
return html.replace("\n\n", "<br><br>").replace("\n", "<br>")
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def status_idle(msg):
|
| 513 |
+
return f'<div class="cs-status cs-idle">{_md2html(msg)}</div>'
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def status_done(msg):
|
| 517 |
+
return f'<div class="cs-status cs-done">{_md2html(msg)}</div>'
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def status_error(msg):
|
| 521 |
+
return f'<div class="cs-status cs-err">⚠️ {msg}</div>'
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def status_running(rep):
|
| 525 |
+
pct = round(rep.frac * 100)
|
| 526 |
+
secs = int(time.time() - rep.start)
|
| 527 |
+
return f"""
|
| 528 |
+
<div class="cs-status cs-run">
|
| 529 |
+
<div class="cs-spin-logo"></div>
|
| 530 |
+
<div class="cs-st-text">{rep.desc}</div>
|
| 531 |
+
<div class="cs-bar"><div style="width:{pct}%"></div></div>
|
| 532 |
+
<div class="cs-st-meta">{pct}% · {secs}s</div>
|
| 533 |
+
</div>"""
|
| 534 |
+
|
| 535 |
+
|
| 536 |
+
def _run_live(core, args, n_outputs, status_index):
|
| 537 |
+
"""Run `core` in a thread and stream only the status panel until it finishes."""
|
| 538 |
+
rep = _Reporter()
|
| 539 |
+
box = {}
|
| 540 |
+
|
| 541 |
+
def work():
|
| 542 |
+
try:
|
| 543 |
+
box["result"] = core(*args, rep)
|
| 544 |
+
except Exception as err: # re-raised in the request thread
|
| 545 |
+
box["error"] = err
|
| 546 |
+
|
| 547 |
+
t = threading.Thread(target=work, daemon=True)
|
| 548 |
+
t.start()
|
| 549 |
+
keep = [gr.update()] * n_outputs
|
| 550 |
+
while t.is_alive():
|
| 551 |
+
frame = list(keep)
|
| 552 |
+
frame[status_index] = status_running(rep)
|
| 553 |
+
yield tuple(frame)
|
| 554 |
+
t.join(timeout=0.6)
|
| 555 |
+
|
| 556 |
+
if "error" in box:
|
| 557 |
+
err = box["error"]
|
| 558 |
+
frame = list(keep)
|
| 559 |
+
frame[status_index] = status_error(str(err) or "Something went wrong.")
|
| 560 |
+
yield tuple(frame)
|
| 561 |
+
if isinstance(err, gr.Error):
|
| 562 |
+
raise err
|
| 563 |
+
raise gr.Error(f"Processing failed: {err}")
|
| 564 |
+
yield box["result"]
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
def _save_output(doc, stem, suffix="_paraphrased"):
|
| 570 |
+
"""Save to a unique folder with a clean, readable file name."""
|
| 571 |
+
safe = re.sub(r"[^A-Za-z0-9_-]+", "_", stem).strip("_") or "document"
|
| 572 |
+
folder = Path(tempfile.mkdtemp(dir=OUT_DIR))
|
| 573 |
+
path = folder / f"{safe}{suffix}.docx"
|
| 574 |
+
doc.save(str(path))
|
| 575 |
+
return str(path)
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def download_button(path):
|
| 579 |
+
"""Direct download link (works even if the file widget is blocked)."""
|
| 580 |
+
if not path or not Path(path).exists():
|
| 581 |
+
return ""
|
| 582 |
+
data = base64.b64encode(Path(path).read_bytes()).decode()
|
| 583 |
+
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
| 584 |
+
name = Path(path).name
|
| 585 |
+
return (f'<a class="cs-dl" download="{name}" href="data:{mime};base64,{data}">'
|
| 586 |
+
f'⬇️ Download {name}</a>')
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
def count_words(text):
|
| 590 |
+
n = len((text or "").split())
|
| 591 |
+
color = BRAND["red"] if n > MAX_PASTE_WORDS else BRAND["grey"]
|
| 592 |
+
return f"<div class='cs-count' style='color:{color}'>{n:,} / {MAX_PASTE_WORDS:,} words</div>"
|
logo.jpg
ADDED
|
logo.png
ADDED
|
packages.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
libreoffice-writer
|
| 2 |
+
libreoffice-math
|
| 3 |
+
libreoffice-calc
|
| 4 |
+
libreoffice-draw
|
| 5 |
+
libreoffice-impress
|
| 6 |
+
fonts-liberation
|
| 7 |
+
fonts-dejavu-core
|
| 8 |
+
fonts-crosextra-carlito
|
| 9 |
+
fonts-crosextra-caladea
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch
|
| 2 |
+
transformers>=4.45,<5
|
| 3 |
+
sentencepiece>=0.2
|
| 4 |
+
protobuf==3.20.3
|
| 5 |
+
python-docx>=1.1.2
|
| 6 |
+
pymupdf
|
| 7 |
+
reportlab
|
smart.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Smart Humanize = detector + humanizer working together.
|
| 3 |
+
|
| 4 |
+
Loop (up to `passes` times):
|
| 5 |
+
1. score every sentence with the RoBERTa AI detector
|
| 6 |
+
2. stop if the document's AI % is at/below the target
|
| 7 |
+
3. paraphrase ONLY the sentences the detector flags (P(AI) >= moderate threshold),
|
| 8 |
+
with slightly bolder variation on each later pass
|
| 9 |
+
4. write the new sentences back in place (Word runs outside the sentence keep their formatting)
|
| 10 |
+
Then score once more and report before/after.
|
| 11 |
+
"""
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
import gradio as gr
|
| 15 |
+
from docx.oxml.ns import qn
|
| 16 |
+
|
| 17 |
+
import detector as D
|
| 18 |
+
import docx_report as DX
|
| 19 |
+
import humanizer as H
|
| 20 |
+
|
| 21 |
+
W_T = qn("w:t")
|
| 22 |
+
_XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
|
| 23 |
+
_UNSAFE_ANCESTORS = {qn("w:fldSimple"), qn("w:hyperlink"), qn("w:ins"), qn("w:smartTag"), qn("w:sdtContent")}
|
| 24 |
+
|
| 25 |
+
# citation / reference markers that must survive a rewrite
|
| 26 |
+
_CITE = re.compile(r"\[\s*\d+(?:\s*[,–\-]\s*\d+)*\s*\]"
|
| 27 |
+
r"|\((?:[A-Z][^()]{0,80}?(?:19|20)\d{2}[a-z]?(?:;\s*[^()]{0,80}?(?:19|20)\d{2}[a-z]?)*)\)")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------------------------------------------------------------- sentence rewriting
|
| 31 |
+
def _guard(original, new):
|
| 32 |
+
"""Reject rewrites that lose meaning-bearing content; re-attach dropped citations."""
|
| 33 |
+
new = (new or "").strip()
|
| 34 |
+
if not new:
|
| 35 |
+
return None
|
| 36 |
+
ow, nw = len(original.split()), len(new.split())
|
| 37 |
+
if nw < 0.5 * ow or nw > 1.8 * ow + 5:
|
| 38 |
+
return None
|
| 39 |
+
# numbers (results, years, units) must be kept
|
| 40 |
+
nums_o = set(re.findall(r"\d+(?:\.\d+)?%?", _CITE.sub(" ", original)))
|
| 41 |
+
nums_n = set(re.findall(r"\d+(?:\.\d+)?%?", new))
|
| 42 |
+
if not nums_o <= nums_n:
|
| 43 |
+
return None
|
| 44 |
+
missing = [c for c in _CITE.findall(original) if c not in new]
|
| 45 |
+
if missing:
|
| 46 |
+
end = re.search(r"[.!?]\s*$", new)
|
| 47 |
+
core = new[:end.start()] if end else new
|
| 48 |
+
new = core.rstrip() + " " + " ".join(missing) + (end.group(0).strip() if end else "")
|
| 49 |
+
if original[:1].isupper() and new[:1].islower():
|
| 50 |
+
new = new[0].upper() + new[1:]
|
| 51 |
+
elif original[:1].islower() and new[:1].isupper() and not new[1:2].isupper():
|
| 52 |
+
new = new[0].lower() + new[1:] # fragment starting mid-sentence
|
| 53 |
+
if not re.search(r"[.!?]$", original.strip()):
|
| 54 |
+
new = re.sub(r"\s*[.!?]+$", "", new) # fragment without end punctuation
|
| 55 |
+
return new
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _rewrite(sentences, temperature, mode, rep, lo, hi):
|
| 59 |
+
if not sentences:
|
| 60 |
+
return [], ""
|
| 61 |
+
out, note = H.paraphrase_paragraphs(sentences, temperature, rep, lo=lo, hi=hi, mode=mode)
|
| 62 |
+
return [_guard(s, n) for s, n in zip(sentences, out)], note
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------- docx: in-place span replacement
|
| 66 |
+
def _run_map(p_el):
|
| 67 |
+
"""[(run, start, end, safe)] over DX.para_text(p_el)."""
|
| 68 |
+
items, pos, depth = [], 0, 0
|
| 69 |
+
for r in DX.para_runs(p_el):
|
| 70 |
+
for fc in r.iter(qn("w:fldChar")):
|
| 71 |
+
t = fc.get(qn("w:fldCharType"))
|
| 72 |
+
depth += 1 if t == "begin" else -1 if t == "end" else 0
|
| 73 |
+
t = DX.run_text(r)
|
| 74 |
+
s, e = pos, pos + len(t)
|
| 75 |
+
pos = e
|
| 76 |
+
safe = DX._simple(r) and depth == 0
|
| 77 |
+
if safe:
|
| 78 |
+
a = r.getparent()
|
| 79 |
+
while a is not None and a.tag != qn("w:p"):
|
| 80 |
+
if a.tag in _UNSAFE_ANCESTORS:
|
| 81 |
+
safe = False
|
| 82 |
+
break
|
| 83 |
+
a = a.getparent()
|
| 84 |
+
rpr = r.find(qn("w:rPr"))
|
| 85 |
+
if safe and rpr is not None and rpr.find(qn("w:vertAlign")) is not None:
|
| 86 |
+
safe = False # superscript citations, formulas
|
| 87 |
+
items.append((r, s, e, safe))
|
| 88 |
+
return items
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def replace_span(p_el, a, b, new):
|
| 92 |
+
"""Replace characters [a, b) of the paragraph text. Returns False (and leaves the
|
| 93 |
+
paragraph untouched) if the span crosses fields, hyperlinks, sub/superscripts or
|
| 94 |
+
non-text runs."""
|
| 95 |
+
hit = [(r, s, e, safe) for r, s, e, safe in _run_map(p_el) if e > a and s < b and e > s]
|
| 96 |
+
if not hit or not all(x[3] for x in hit):
|
| 97 |
+
return False
|
| 98 |
+
for i, (r, s, e, _) in enumerate(hit):
|
| 99 |
+
te = r.find(W_T)
|
| 100 |
+
t = te.text or ""
|
| 101 |
+
before = t[:max(0, a - s)]
|
| 102 |
+
after = t[b - s:] if b < e else ""
|
| 103 |
+
te.text = (before + new + after) if i == 0 else after
|
| 104 |
+
te.set(_XML_SPACE, "preserve")
|
| 105 |
+
if i > 0 and not te.text and r.getparent() is not None:
|
| 106 |
+
r.getparent().remove(r) # drop runs emptied by the merge
|
| 107 |
+
return True
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def safe_subspan(p_el, a, b, min_words=5):
|
| 111 |
+
"""Largest part of [a, b) that lies in plain text runs (so a sentence ending in a
|
| 112 |
+
superscript citation or a field can still have its prose rewritten)."""
|
| 113 |
+
runs = [(s, e, safe) for _, s, e, safe in _run_map(p_el) if e > a and s < b and e > s]
|
| 114 |
+
if runs and all(x[2] for x in runs):
|
| 115 |
+
return a, b
|
| 116 |
+
best, cur = None, None
|
| 117 |
+
for s, e, safe in runs + [(b, b, False)]:
|
| 118 |
+
if safe:
|
| 119 |
+
cur = (max(a, s) if cur is None else cur[0], min(b, e))
|
| 120 |
+
elif cur is not None:
|
| 121 |
+
if best is None or cur[1] - cur[0] > best[1] - best[0]:
|
| 122 |
+
best = cur
|
| 123 |
+
cur = None
|
| 124 |
+
if best is None:
|
| 125 |
+
return None
|
| 126 |
+
raw = DX.para_text(p_el)
|
| 127 |
+
x, y = best
|
| 128 |
+
while x < y and raw[x].isspace():
|
| 129 |
+
x += 1
|
| 130 |
+
while y > x and raw[y - 1].isspace():
|
| 131 |
+
y -= 1
|
| 132 |
+
frag = raw[x:y]
|
| 133 |
+
if len(frag.split()) < min_words or (y - x) < 0.5 * (b - a):
|
| 134 |
+
return None
|
| 135 |
+
return x, y
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ---------------------------------------------------------------- scoring helpers
|
| 139 |
+
def _score_doc(doc):
|
| 140 |
+
blocks = D.classify(D.extract_docx_live(doc))
|
| 141 |
+
if not any(b.kind == "para" for b in blocks):
|
| 142 |
+
raise gr.Error("No qualifying prose found (the detector needs paragraphs of 25+ words).")
|
| 143 |
+
D.analyse(blocks)
|
| 144 |
+
return blocks
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _score_paras(paras):
|
| 148 |
+
blocks = D.classify([D.Block(D._norm(p)) for p in paras])
|
| 149 |
+
if not any(b.kind == "para" for b in blocks):
|
| 150 |
+
raise gr.Error("No qualifying prose found (the detector needs paragraphs of 25+ words, "
|
| 151 |
+
"separated by a blank line).")
|
| 152 |
+
D.analyse(blocks)
|
| 153 |
+
return blocks
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def detector_pct_text(text, hi=0.80, mod=0.50):
|
| 157 |
+
try:
|
| 158 |
+
paras = [p for p in re.split(r"\n\s*\n", (text or "").replace("\r", "")) if D._norm(p)]
|
| 159 |
+
return D.summarise(_score_paras(paras), hi, mod)["pct"]
|
| 160 |
+
except Exception:
|
| 161 |
+
return None
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def detector_pct_docx(path, hi=0.80, mod=0.50):
|
| 165 |
+
try:
|
| 166 |
+
return D.summarise(_score_doc(DX.open_clean(path)), hi, mod)["pct"]
|
| 167 |
+
except Exception:
|
| 168 |
+
return None
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ---------------------------------------------------------------- pipelines
|
| 172 |
+
def _log_line(k, pct, n_flag=None, n_done=None):
|
| 173 |
+
s = f"Pass {k}: AI score {pct}%"
|
| 174 |
+
if n_flag is not None:
|
| 175 |
+
s += f" → rewrote {n_done} of {n_flag} flagged sentences"
|
| 176 |
+
return s
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def smart_docx(path, target, passes, temperature, mode, hi, mod, rep):
|
| 180 |
+
rep(0.03, "Opening Word document")
|
| 181 |
+
doc = DX.open_clean(path)
|
| 182 |
+
log, first, blocks, note, dirty = [], None, None, "", False
|
| 183 |
+
orig_text = "\n".join(b.text for b in D.classify(D.extract_docx_live(doc)) if b.kind == "para")
|
| 184 |
+
step = 0.8 / max(1, passes)
|
| 185 |
+
for k in range(1, int(passes) + 1):
|
| 186 |
+
base = 0.05 + step * (k - 1)
|
| 187 |
+
rep(base, f"Pass {k}: running AI detector")
|
| 188 |
+
blocks, dirty = _score_doc(doc), False
|
| 189 |
+
stats = D.summarise(blocks, hi, mod)
|
| 190 |
+
first = first or stats
|
| 191 |
+
if stats["pct"] <= target:
|
| 192 |
+
log.append(_log_line(k, stats["pct"]) + " — target reached")
|
| 193 |
+
break
|
| 194 |
+
jobs = [] # (block, span, sentence)
|
| 195 |
+
for b in blocks:
|
| 196 |
+
if b.kind != "para" or b.el is None:
|
| 197 |
+
continue
|
| 198 |
+
for sp, s, p in zip(b.cspans, b.sents, b.scores):
|
| 199 |
+
if sp and p >= mod and len(s.split()) >= 5:
|
| 200 |
+
sub = safe_subspan(b.el, *sp)
|
| 201 |
+
if sub:
|
| 202 |
+
jobs.append((b, sub, b.raw[sub[0]:sub[1]]))
|
| 203 |
+
if not jobs:
|
| 204 |
+
log.append(_log_line(k, stats["pct"]) + " — no rewritable sentences left")
|
| 205 |
+
break
|
| 206 |
+
temp = min(1.5, float(temperature) + 0.15 * (k - 1))
|
| 207 |
+
new, note = _rewrite([j[2] for j in jobs], temp, mode, rep, base + 0.1 * step, base + 0.9 * step)
|
| 208 |
+
done = 0
|
| 209 |
+
# right-to-left inside each paragraph so earlier offsets stay valid
|
| 210 |
+
for (b, (a, e), _), n in sorted(zip(jobs, new), key=lambda x: (id(x[0][0]), -x[0][1][0])):
|
| 211 |
+
if n and n != b.raw[a:e] and replace_span(b.el, a, e, n):
|
| 212 |
+
done += 1
|
| 213 |
+
dirty = done > 0
|
| 214 |
+
log.append(_log_line(k, stats["pct"], len(jobs), done))
|
| 215 |
+
if not done:
|
| 216 |
+
break
|
| 217 |
+
rep(0.88, "Final AI check")
|
| 218 |
+
if dirty:
|
| 219 |
+
blocks = _score_doc(doc)
|
| 220 |
+
after = D.summarise(blocks, hi, mod)
|
| 221 |
+
new_text = "\n".join(b.text for b in blocks if b.kind == "para")
|
| 222 |
+
out = H._save_output(doc, re.sub(r"\.docx$", "", path.split("/")[-1], flags=re.I), "_humanized")
|
| 223 |
+
rep(0.95, "Preparing results")
|
| 224 |
+
return dict(before=first, after=after, log=log, note=note, blocks=blocks, path=out,
|
| 225 |
+
orig=orig_text, new=new_text)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def smart_text(text, target, passes, temperature, mode, hi, mod, rep):
|
| 229 |
+
rep(0.03, "Reading pasted text")
|
| 230 |
+
paras = [p for p in re.split(r"\n\s*\n", (text or "").replace("\r", "")) if D._norm(p)]
|
| 231 |
+
orig = "\n\n".join(D._norm(p) for p in paras)
|
| 232 |
+
log, first, blocks, note, dirty = [], None, None, "", False
|
| 233 |
+
step = 0.8 / max(1, passes)
|
| 234 |
+
for k in range(1, int(passes) + 1):
|
| 235 |
+
base = 0.05 + step * (k - 1)
|
| 236 |
+
rep(base, f"Pass {k}: running AI detector")
|
| 237 |
+
blocks, dirty = _score_paras(paras), False
|
| 238 |
+
stats = D.summarise(blocks, hi, mod)
|
| 239 |
+
first = first or stats
|
| 240 |
+
if stats["pct"] <= target:
|
| 241 |
+
log.append(_log_line(k, stats["pct"]) + " — target reached")
|
| 242 |
+
break
|
| 243 |
+
jobs = [(bi, si, s) for bi, b in enumerate(blocks) if b.kind == "para"
|
| 244 |
+
for si, (s, p) in enumerate(zip(b.sents, b.scores)) if p >= mod and len(s.split()) >= 5]
|
| 245 |
+
if not jobs:
|
| 246 |
+
log.append(_log_line(k, stats["pct"]) + " — no rewritable sentences left")
|
| 247 |
+
break
|
| 248 |
+
temp = min(1.5, float(temperature) + 0.15 * (k - 1))
|
| 249 |
+
new, note = _rewrite([j[2] for j in jobs], temp, mode, rep, base + 0.1 * step, base + 0.9 * step)
|
| 250 |
+
done = 0
|
| 251 |
+
for (bi, si, s), n in zip(jobs, new):
|
| 252 |
+
if n and n != s:
|
| 253 |
+
blocks[bi].sents[si] = n
|
| 254 |
+
done += 1
|
| 255 |
+
paras = [" ".join(b.sents) if b.kind == "para" else b.text for b in blocks]
|
| 256 |
+
dirty = done > 0
|
| 257 |
+
log.append(_log_line(k, stats["pct"], len(jobs), done))
|
| 258 |
+
if not done:
|
| 259 |
+
break
|
| 260 |
+
rep(0.88, "Final AI check")
|
| 261 |
+
if dirty:
|
| 262 |
+
blocks = _score_paras(paras)
|
| 263 |
+
after = D.summarise(blocks, hi, mod)
|
| 264 |
+
out_text = "\n\n".join(paras)
|
| 265 |
+
from docx import Document
|
| 266 |
+
d = Document()
|
| 267 |
+
for p in paras:
|
| 268 |
+
d.add_paragraph(p)
|
| 269 |
+
path = H._save_output(d, "pasted_text", "_humanized")
|
| 270 |
+
rep(0.95, "Preparing results")
|
| 271 |
+
return dict(before=first, after=after, log=log, note=note, blocks=blocks, path=path,
|
| 272 |
+
orig=orig, new=out_text, text=out_text)
|