Rasheed commited on
Commit
aa35e7b
·
verified ·
1 Parent(s): 2e01a82

Upload 13 files

Browse files
Files changed (12) hide show
  1. README.md +64 -8
  2. app.py +656 -0
  3. auth.py +191 -0
  4. detector.py +689 -0
  5. docx_report.py +360 -0
  6. favicon.png +0 -0
  7. humanizer.py +592 -0
  8. logo.jpg +0 -0
  9. logo.png +0 -0
  10. packages.txt +9 -0
  11. requirements.txt +7 -0
  12. smart.py +272 -0
README.md CHANGED
@@ -1,15 +1,71 @@
1
  ---
2
- title: AI Writing Suite
3
- emoji: 🏢
4
- colorFrom: pink
5
  colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.28.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
11
  license: apache-2.0
12
- short_description: Smart_AI
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 &nbsp;|&nbsp; <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)