Rasheed commited on
Commit
482fe8e
·
verified ·
1 Parent(s): 89be0b7

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +21 -12
  2. app.py +447 -199
  3. logo.png +0 -0
  4. requirements.txt +1 -0
README.md CHANGED
@@ -1,28 +1,37 @@
1
  ---
2
- title: Word DOCX Paraphraser ZeroGPU
3
- emoji: 🦜
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: gradio
7
  sdk_version: 5.49.1
8
  app_file: app.py
9
- pinned: false
 
10
  ---
11
 
12
- # Word DOCX Paraphraser — Gradio + ZeroGPU
13
 
14
- Native Gradio Hugging Face Space for uploading a `.docx`, paraphrasing eligible
15
- prose with `prithivida/parrot_paraphraser_on_T5`, injecting the revised wording
16
- back into the original DOCX structure, and downloading the result.
 
 
 
 
 
 
 
 
 
17
 
18
  ## Hardware
19
 
20
- Select **ZeroGPU** in the Hugging Face Space hardware settings.
21
 
22
  ## Files
23
 
24
  - `app.py`
25
  - `requirements.txt`
 
26
  - `README.md`
27
-
28
- No Dockerfile, Streamlit server, FastAPI server, or manual Uvicorn process is used.
 
1
  ---
2
+ title: CS Academic Paraphraser
3
+ emoji: ✨
4
+ colorFrom: indigo
5
+ colorTo: pink
6
  sdk: gradio
7
  sdk_version: 5.49.1
8
  app_file: app.py
9
+ pinned: true
10
+ short_description: Paraphrase .docx or pasted text, keep Word layout
11
  ---
12
 
13
+ # CS Academic Paraphraser — by CS Software Solutions
14
 
15
+ Upload a `.docx` **or paste text**, paraphrase eligible prose with
16
+ `prithivida/parrot_paraphraser_on_T5`, and download the result. For Word files the
17
+ new wording is injected back into the original document, so sections, styles,
18
+ tables, images, numbering and headers/footers stay in place.
19
+
20
+ After each run the app shows **tentative indicators**:
21
+
22
+ - **Similarity to source** — word n-gram overlap between output and your original
23
+ - **AI indicator (input / output)** — stylometric heuristic (sentence-length variation, stock phrasing, uniformity)
24
+ - **Vocabulary changed** — share of new wording
25
+
26
+ These are local estimates, not Turnitin / iThenticate / AI-detector results.
27
 
28
  ## Hardware
29
 
30
+ Settings → Hardware → **ZeroGPU**.
31
 
32
  ## Files
33
 
34
  - `app.py`
35
  - `requirements.txt`
36
+ - `logo.png` (brand logo shown in the header)
37
  - `README.md`
 
 
app.py CHANGED
@@ -1,5 +1,7 @@
 
1
  import os
2
  import re
 
3
  import tempfile
4
  import threading
5
  from pathlib import Path
@@ -10,38 +12,205 @@ import torch
10
  from docx import Document
11
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  MODEL_ID = os.getenv("PARROT_MODEL", "prithivida/parrot_paraphraser_on_T5")
14
-
15
- # Model stays in CPU RAM while the Space is idle.
16
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
17
  model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).eval()
18
  model_lock = threading.Lock()
19
 
 
 
 
20
 
 
 
 
21
  def split_sentences(text):
22
- """Conservative sentence splitting for normal prose."""
23
- parts = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text.strip())
24
  return [p.strip() for p in parts if p.strip()]
25
 
26
 
27
  def should_process(text, min_words):
28
  text = (text or "").strip()
29
-
30
  if len(text.split()) < int(min_words):
31
  return False
32
-
33
- # Skip standalone URLs/emails.
34
  if re.fullmatch(r"https?://\S+|www\.\S+|\S+@\S+\.\S+", text):
35
  return False
36
-
37
- # Skip lines dominated by numbers/symbols/equation-like content.
38
  alpha = sum(ch.isalpha() for ch in text)
39
  if alpha < max(5, int(len(text) * 0.35)):
40
  return False
41
-
42
  return True
43
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  def iter_table_paragraphs(table):
46
  for row in table.rows:
47
  for cell in row.cells:
@@ -52,12 +221,6 @@ def iter_table_paragraphs(table):
52
 
53
 
54
  def collect_paragraphs(doc):
55
- """
56
- Yield unique paragraphs from:
57
- - document body
58
- - tables / nested tables
59
- - headers / footers
60
- """
61
  seen = set()
62
 
63
  def unique(paragraphs):
@@ -68,76 +231,48 @@ def collect_paragraphs(doc):
68
  yield paragraph
69
 
70
  yield from unique(doc.paragraphs)
71
-
72
  for table in doc.tables:
73
  yield from unique(iter_table_paragraphs(table))
74
-
75
  for section in doc.sections:
76
- parts = (
77
- section.header,
78
- section.footer,
79
- section.first_page_header,
80
- section.first_page_footer,
81
- section.even_page_header,
82
- section.even_page_footer,
83
- )
84
- for part in parts:
85
  yield from unique(part.paragraphs)
86
  for table in part.tables:
87
  yield from unique(iter_table_paragraphs(table))
88
 
89
 
90
- def replace_text_preserving_paragraph(paragraph, new_text):
91
- """
92
- Replace ordinary textual runs without rebuilding the paragraph.
 
 
 
93
 
94
- This retains paragraph properties, style, numbering, tables, drawings,
95
- bookmarks and other non-text paragraph XML. The first usable text run
96
- becomes the target, preserving that run's character formatting.
97
- """
98
- runs = list(paragraph.runs)
99
 
 
 
100
  if not runs:
101
  paragraph.add_run(new_text)
102
  return
103
-
104
- target = None
105
- for run in runs:
106
- has_drawing = bool(run._r.xpath(".//w:drawing"))
107
- has_object = bool(run._r.xpath(".//w:object"))
108
- has_field = bool(run._r.xpath(".//w:fldChar"))
109
- if not (has_drawing or has_object or has_field):
110
- target = run
111
- break
112
-
113
  if target is None:
114
  target = paragraph.add_run()
115
-
116
  target.text = new_text
117
-
118
  for run in runs:
119
- if run is target:
120
- continue
121
-
122
- # Do not disturb drawings, embedded objects or field-code runs.
123
- protected = (
124
- bool(run._r.xpath(".//w:drawing"))
125
- or bool(run._r.xpath(".//w:object"))
126
- or bool(run._r.xpath(".//w:fldChar"))
127
- )
128
- if not protected:
129
  run.text = ""
130
 
131
 
 
 
 
132
  @spaces.GPU(duration=120)
133
  def paraphrase_batch(texts, temperature):
134
- """
135
- One ZeroGPU allocation processes a list of paragraph/sentence strings.
136
- This is much more efficient than requesting a GPU separately per sentence.
137
- """
138
  if not texts:
139
  return []
140
-
141
  if not torch.cuda.is_available():
142
  raise RuntimeError("ZeroGPU allocation did not expose CUDA. Please try again.")
143
 
@@ -147,21 +282,15 @@ def paraphrase_batch(texts, temperature):
147
 
148
  with model_lock:
149
  model.to(device)
150
-
151
  try:
152
  with torch.inference_mode():
153
  for start in range(0, len(texts), 8):
154
  chunk = texts[start:start + 8]
155
-
156
  encoded = tokenizer(
157
- ["paraphrase: " + text for text in chunk],
158
- return_tensors="pt",
159
- padding=True,
160
- truncation=True,
161
- max_length=256,
162
  )
163
  encoded = {k: v.to(device) for k, v in encoded.items()}
164
-
165
  generated = model.generate(
166
  **encoded,
167
  do_sample=True,
@@ -171,69 +300,59 @@ def paraphrase_batch(texts, temperature):
171
  max_new_tokens=180,
172
  no_repeat_ngram_size=3,
173
  )
174
-
175
- decoded = tokenizer.batch_decode(
176
- generated.detach().cpu(),
177
- skip_special_tokens=True,
178
- )
179
-
180
  outputs.extend(
181
- [
182
- candidate.strip() if candidate.strip() else source
183
- for source, candidate in zip(chunk, decoded)
184
- ]
185
  )
186
-
187
  del encoded, generated
188
-
189
  finally:
190
  model.to("cpu")
191
  if torch.cuda.is_available():
192
  torch.cuda.empty_cache()
193
-
194
  return outputs
195
 
196
 
197
- def paraphrase_paragraphs(texts, temperature):
198
- """
199
- Preserve sentence order within each paragraph while allowing batch GPU inference.
200
- """
201
- sentence_groups = []
202
- flat_sentences = []
203
-
204
  for text in texts:
205
- sentences = split_sentences(text)
206
- if not sentences:
207
- sentences = [text]
208
-
209
- start = len(flat_sentences)
210
- flat_sentences.extend(sentences)
211
- sentence_groups.append((start, len(flat_sentences)))
212
-
213
- rewritten_sentences = paraphrase_batch(flat_sentences, temperature)
214
-
215
- rewritten_paragraphs = []
216
- for start, end in sentence_groups:
217
- rewritten_paragraphs.append(" ".join(rewritten_sentences[start:end]))
218
-
219
- return rewritten_paragraphs
 
 
 
 
 
 
 
220
 
221
 
 
 
 
222
  def process_docx(file_path, temperature, min_words, progress=gr.Progress()):
223
  if not file_path:
224
  raise gr.Error("Please upload a .docx file.")
225
-
226
  source_path = Path(file_path)
227
  if source_path.suffix.lower() != ".docx":
228
  raise gr.Error("Only .docx Word documents are supported.")
229
 
230
  progress(0.05, desc="Opening Word document")
231
-
232
  doc = Document(str(source_path))
233
 
234
- targets = []
235
- originals = []
236
-
237
  for paragraph in collect_paragraphs(doc):
238
  text = paragraph.text
239
  if should_process(text, min_words):
@@ -243,116 +362,245 @@ def process_docx(file_path, temperature, min_words, progress=gr.Progress()):
243
  if not originals:
244
  raise gr.Error("No eligible prose paragraphs were found in the document.")
245
 
246
- progress(0.15, desc=f"Paraphrasing {len(originals)} eligible paragraphs")
247
-
248
- revised = paraphrase_paragraphs(originals, temperature)
249
-
250
- progress(0.85, desc="Injecting revised text into original structure")
251
-
252
- changed = 0
253
- preview_lines = []
254
 
 
 
255
  for paragraph, before, after in zip(targets, originals, revised):
256
- if after and after.strip() and after.strip() != before.strip():
257
- replace_text_preserving_paragraph(paragraph, after.strip())
 
258
  changed += 1
 
 
 
 
259
 
260
- if len(preview_lines) < 10:
261
- preview_lines.append(
262
- f"### Change {len(preview_lines) + 1}\n"
263
- f"**Original:** {before}\n\n"
264
- f"**Paraphrased:** {after.strip()}"
265
- )
266
-
267
- fd, output_path = tempfile.mkstemp(
268
- prefix=f"{source_path.stem}_paraphrased_",
269
- suffix=".docx",
270
- )
271
  os.close(fd)
272
  doc.save(output_path)
273
-
274
  progress(1.0, desc="Completed")
275
 
276
  summary = (
277
- f"Completed successfully.\n\n"
278
- f"- Eligible paragraphs: **{len(originals)}**\n"
279
- f"- Updated paragraphs: **{changed}**\n"
280
- f"- Output structure: edited in the original DOCX rather than rebuilt."
281
  )
 
 
282
 
283
- preview = "\n\n---\n\n".join(preview_lines)
284
- if not preview:
285
- preview = "No textual changes were generated."
286
 
287
- return output_path, summary, preview
 
 
 
 
 
 
 
 
 
288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
 
290
- with gr.Blocks(title="Word DOCX Paraphraser — ZeroGPU") as demo:
291
- gr.Markdown(
292
- """
293
- # 🦜 Word DOCX Paraphraser
294
- ### Gradio + Hugging Face ZeroGPU
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
- Upload an existing **`.docx` Word document**. The app paraphrases eligible prose
297
- and injects the revised text back into the original Word document instead of
298
- creating a new layout from scratch.
 
 
 
 
 
 
299
 
300
- **Designed to retain:** sections, margins/page setup, paragraph positions and
301
- styles, tables, images, headers/footers, numbering and other document objects.
 
 
 
 
 
 
 
 
 
 
302
 
303
- > **Formatting note:** when a rewritten paragraph contains several differently
304
- > formatted text runs, the new wording uses the first suitable run's character
305
- > formatting. This avoids rebuilding the document, but word-by-word mixed
306
- > bold/italic formatting cannot always be mapped exactly after the wording changes.
 
 
307
  """
308
- )
309
 
310
- with gr.Row():
311
- with gr.Column(scale=1):
312
- input_file = gr.File(
313
- label="Upload Word document (.docx)",
314
- file_types=[".docx"],
315
- type="filepath",
316
- )
317
-
318
- temperature = gr.Slider(
319
- minimum=0.3,
320
- maximum=1.5,
321
- value=0.9,
322
- step=0.1,
323
- label="Paraphrase variation",
324
- )
325
-
326
- min_words = gr.Slider(
327
- minimum=5,
328
- maximum=30,
329
- value=8,
330
- step=1,
331
- label="Minimum words per paragraph",
332
- )
333
-
334
- run_button = gr.Button(
335
- "Paraphrase and inject into DOCX",
336
- variant="primary",
337
- )
338
-
339
- with gr.Column(scale=1):
340
- status = gr.Markdown()
341
- output_file = gr.File(
342
- label="Download paraphrased Word document",
343
- interactive=False,
344
- )
345
-
346
- preview = gr.Markdown(label="Preview")
347
-
348
- run_button.click(
349
- fn=process_docx,
350
- inputs=[input_file, temperature, min_words],
351
- outputs=[output_file, status, preview],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  api_name="paraphrase_docx",
353
  )
 
 
 
 
 
 
 
 
 
354
 
355
- # Native Gradio Space entry point.
356
- # Do not add FastAPI, uvicorn.run(), or manual port binding.
357
  if __name__ == "__main__":
358
  demo.launch()
 
1
+ import base64
2
  import os
3
  import re
4
+ import statistics
5
  import tempfile
6
  import threading
7
  from pathlib import Path
 
12
  from docx import Document
13
  from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
14
 
15
+ # ---------------------------------------------------------------------------
16
+ # Brand (colours sampled from the CS Software Solutions logo)
17
+ # ---------------------------------------------------------------------------
18
+ BRAND = {
19
+ "navy": "#07228B",
20
+ "purple": "#47148B",
21
+ "magenta": "#E92E7F",
22
+ "red": "#E12B41",
23
+ "orange": "#FE5828",
24
+ "yellow": "#F5C518",
25
+ "green": "#50BF49",
26
+ "sky": "#50B4EF",
27
+ "grey": "#4A4A4A",
28
+ }
29
+ RAINBOW = (
30
+ f"linear-gradient(90deg, {BRAND['purple']}, {BRAND['navy']}, {BRAND['sky']}, "
31
+ f"{BRAND['green']}, {BRAND['yellow']}, {BRAND['orange']}, {BRAND['red']}, {BRAND['magenta']})"
32
+ )
33
+ APP_NAME = "CS Academic Paraphraser"
34
+ SITE_URL = "https://cssoftwaresolutions.in"
35
+
36
+ LOGO_PATH = Path(__file__).parent / "logo.png"
37
+ LOGO_SRC = ""
38
+ if LOGO_PATH.exists():
39
+ LOGO_SRC = "data:image/png;base64," + base64.b64encode(LOGO_PATH.read_bytes()).decode()
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Model (kept in CPU RAM while idle; moved to GPU only inside @spaces.GPU)
43
+ # ---------------------------------------------------------------------------
44
  MODEL_ID = os.getenv("PARROT_MODEL", "prithivida/parrot_paraphraser_on_T5")
 
 
45
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
46
  model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).eval()
47
  model_lock = threading.Lock()
48
 
49
+ GPU_CHUNK = 64 # sentences per ZeroGPU allocation
50
+ MAX_PASTE_WORDS = 6000 # keeps a single paste request within quota
51
+
52
 
53
+ # ---------------------------------------------------------------------------
54
+ # Text helpers
55
+ # ---------------------------------------------------------------------------
56
  def split_sentences(text):
57
+ parts = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9\"'(\[])", text.strip())
 
58
  return [p.strip() for p in parts if p.strip()]
59
 
60
 
61
  def should_process(text, min_words):
62
  text = (text or "").strip()
 
63
  if len(text.split()) < int(min_words):
64
  return False
 
 
65
  if re.fullmatch(r"https?://\S+|www\.\S+|\S+@\S+\.\S+", text):
66
  return False
 
 
67
  alpha = sum(ch.isalpha() for ch in text)
68
  if alpha < max(5, int(len(text) * 0.35)):
69
  return False
 
70
  return True
71
 
72
 
73
+ def words(text):
74
+ return re.findall(r"[A-Za-z0-9']+", (text or "").lower())
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Tentative indicators (local heuristics — NOT Turnitin / iThenticate / GPTZero)
79
+ # ---------------------------------------------------------------------------
80
+ def _ngrams(tokens, n):
81
+ return {tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)}
82
+
83
+
84
+ def similarity_to_source(original, revised):
85
+ """Share of the revised text's word n-grams that also occur in the original."""
86
+ a, b = words(original), words(revised)
87
+ if len(b) < 4 or len(a) < 4:
88
+ return 0.0
89
+ scores, weights = [], []
90
+ for n, w in ((2, 0.2), (3, 0.35), (4, 0.45)):
91
+ gb = _ngrams(b, n)
92
+ if not gb:
93
+ continue
94
+ scores.append(len(gb & _ngrams(a, n)) / len(gb))
95
+ weights.append(w)
96
+ if not weights:
97
+ return 0.0
98
+ return 100.0 * sum(s * w for s, w in zip(scores, weights)) / sum(weights)
99
+
100
+
101
+ AI_MARKERS = [
102
+ "delve", "delves", "delving", "furthermore", "moreover", "additionally",
103
+ "in conclusion", "it is important to note", "it is worth noting", "plays a crucial role",
104
+ "plays a pivotal role", "pivotal", "crucial", "comprehensive", "leverage", "leveraging",
105
+ "seamless", "seamlessly", "robust", "landscape", "underscore", "underscores",
106
+ "notably", "in today's", "fostering", "foster", "multifaceted", "intricate",
107
+ "realm", "tapestry", "navigate", "navigating", "holistic", "paramount",
108
+ "a testament to", "shed light", "sheds light", "ever-evolving", "cutting-edge",
109
+ "in the realm of", "overall,", "ultimately", "enhance", "enhancing", "streamline",
110
+ "valuable insights", "key role", "vital role", "significantly",
111
+ ]
112
+
113
+
114
+ def ai_likelihood(text):
115
+ """Stylometric estimate (0–100) of machine-like writing."""
116
+ toks = words(text)
117
+ if len(toks) < 30:
118
+ return None
119
+ sents = split_sentences(text) or [text]
120
+ lengths = [len(words(s)) for s in sents if words(s)]
121
+
122
+ # 1. Burstiness: humans vary sentence length more than LLMs.
123
+ if len(lengths) >= 3:
124
+ cv = statistics.pstdev(lengths) / max(1.0, statistics.mean(lengths))
125
+ burst = min(1.0, max(0.0, (0.60 - cv) / 0.45))
126
+ else:
127
+ burst = 0.5
128
+
129
+ # 2. Density of stock "LLM" vocabulary per 100 words.
130
+ low = " " + " ".join(toks) + " "
131
+ raw = (text or "").lower()
132
+ hits = sum(raw.count(m) if " " in m or "," in m else low.count(f" {m} ") for m in AI_MARKERS)
133
+ marker = min(1.0, (hits / len(toks) * 100) / 2.5)
134
+
135
+ # 3. Uniformity: mid-length sentences, repeated openers, low lexical variety.
136
+ mean_len = statistics.mean(lengths)
137
+ band = 1.0 if 16 <= mean_len <= 28 else 0.4
138
+ openers = [words(s)[0] for s in sents if words(s)]
139
+ repeat = 1 - len(set(openers)) / len(openers) if openers else 0
140
+ window = toks[:400]
141
+ ttr = len(set(window)) / len(window)
142
+ variety = min(1.0, max(0.0, (0.62 - ttr) / 0.25))
143
+ uniform = 0.45 * band + 0.3 * min(1.0, repeat * 2) + 0.25 * variety
144
+
145
+ score = 100 * (0.40 * burst + 0.35 * marker + 0.25 * uniform)
146
+ return float(max(3.0, min(97.0, score)))
147
+
148
+
149
+ def words_changed(original, revised):
150
+ a, b = words(original), words(revised)
151
+ if not b:
152
+ return 0.0
153
+ return 100.0 * (1 - len(set(b) & set(a)) / len(set(b)))
154
+
155
+
156
+ def _level(value, invert=False):
157
+ if value is None:
158
+ return "#9aa0a6", "n/a"
159
+ v = 100 - value if invert else value
160
+ if v < 30:
161
+ return BRAND["green"], "Low"
162
+ if v < 55:
163
+ return BRAND["orange"], "Moderate"
164
+ return BRAND["red"], "High"
165
+
166
+
167
+ def _gauge(title, value, caption, invert=False, fixed_color=None):
168
+ color, label = _level(value, invert)
169
+ if fixed_color:
170
+ color = fixed_color
171
+ label = "Rewrite depth"
172
+ pct = 0 if value is None else round(value)
173
+ shown = "—" if value is None else f"{pct}%"
174
+ return f"""
175
+ <div class="cs-gauge">
176
+ <div class="cs-ring" style="--p:{pct};--c:{color}">
177
+ <div class="cs-ring-inner"><span>{shown}</span></div>
178
+ </div>
179
+ <div class="cs-gauge-title">{title}</div>
180
+ <div class="cs-chip" style="background:{color}1f;color:{color}">{label}</div>
181
+ <div class="cs-gauge-cap">{caption}</div>
182
+ </div>"""
183
+
184
+
185
+ def score_panel(original, revised):
186
+ sim = similarity_to_source(original, revised)
187
+ ai_in = ai_likelihood(original)
188
+ ai_out = ai_likelihood(revised)
189
+ changed = words_changed(original, revised)
190
+ return f"""
191
+ <div class="cs-scores">
192
+ {_gauge("Similarity to source", sim, "Word n-gram overlap with your original")}
193
+ {_gauge("AI indicator · input", ai_in, "Stylometric estimate of the original")}
194
+ {_gauge("AI indicator · output", ai_out, "Stylometric estimate after paraphrasing")}
195
+ {_gauge("Vocabulary changed", changed, "Share of new wording", fixed_color=BRAND["navy"])}
196
+ </div>
197
+ <div class="cs-disclaimer">
198
+ <b>Tentative indicators only.</b> Similarity is measured against your own original text,
199
+ not against internet or journal databases, and the AI indicator is a stylometric heuristic.
200
+ Results will differ from Turnitin, iThenticate or other detectors. Always review the
201
+ output for meaning, technical terms and citations before use.
202
+ </div>"""
203
+
204
+
205
+ EMPTY_SCORES = """
206
+ <div class="cs-scores cs-empty">
207
+ <div>Scores appear here after paraphrasing — similarity to source, AI indicators and rewrite depth.</div>
208
+ </div>"""
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # DOCX structure helpers
213
+ # ---------------------------------------------------------------------------
214
  def iter_table_paragraphs(table):
215
  for row in table.rows:
216
  for cell in row.cells:
 
221
 
222
 
223
  def collect_paragraphs(doc):
 
 
 
 
 
 
224
  seen = set()
225
 
226
  def unique(paragraphs):
 
231
  yield paragraph
232
 
233
  yield from unique(doc.paragraphs)
 
234
  for table in doc.tables:
235
  yield from unique(iter_table_paragraphs(table))
 
236
  for section in doc.sections:
237
+ for part in (
238
+ section.header, section.footer,
239
+ section.first_page_header, section.first_page_footer,
240
+ section.even_page_header, section.even_page_footer,
241
+ ):
 
 
 
 
242
  yield from unique(part.paragraphs)
243
  for table in part.tables:
244
  yield from unique(iter_table_paragraphs(table))
245
 
246
 
247
+ def _is_protected(run):
248
+ return bool(
249
+ run._r.xpath(".//w:drawing")
250
+ or run._r.xpath(".//w:object")
251
+ or run._r.xpath(".//w:fldChar")
252
+ )
253
 
 
 
 
 
 
254
 
255
+ def replace_text_preserving_paragraph(paragraph, new_text):
256
+ runs = list(paragraph.runs)
257
  if not runs:
258
  paragraph.add_run(new_text)
259
  return
260
+ target = next((r for r in runs if not _is_protected(r)), None)
 
 
 
 
 
 
 
 
 
261
  if target is None:
262
  target = paragraph.add_run()
 
263
  target.text = new_text
 
264
  for run in runs:
265
+ if run is not target and not _is_protected(run):
 
 
 
 
 
 
 
 
 
266
  run.text = ""
267
 
268
 
269
+ # ---------------------------------------------------------------------------
270
+ # Paraphrasing
271
+ # ---------------------------------------------------------------------------
272
  @spaces.GPU(duration=120)
273
  def paraphrase_batch(texts, temperature):
 
 
 
 
274
  if not texts:
275
  return []
 
276
  if not torch.cuda.is_available():
277
  raise RuntimeError("ZeroGPU allocation did not expose CUDA. Please try again.")
278
 
 
282
 
283
  with model_lock:
284
  model.to(device)
 
285
  try:
286
  with torch.inference_mode():
287
  for start in range(0, len(texts), 8):
288
  chunk = texts[start:start + 8]
 
289
  encoded = tokenizer(
290
+ ["paraphrase: " + t for t in chunk],
291
+ return_tensors="pt", padding=True, truncation=True, max_length=256,
 
 
 
292
  )
293
  encoded = {k: v.to(device) for k, v in encoded.items()}
 
294
  generated = model.generate(
295
  **encoded,
296
  do_sample=True,
 
300
  max_new_tokens=180,
301
  no_repeat_ngram_size=3,
302
  )
303
+ decoded = tokenizer.batch_decode(generated.detach().cpu(), skip_special_tokens=True)
 
 
 
 
 
304
  outputs.extend(
305
+ c.strip() if c.strip() else s for s, c in zip(chunk, decoded)
 
 
 
306
  )
 
307
  del encoded, generated
 
308
  finally:
309
  model.to("cpu")
310
  if torch.cuda.is_available():
311
  torch.cuda.empty_cache()
 
312
  return outputs
313
 
314
 
315
+ def paraphrase_paragraphs(texts, temperature, progress=None, lo=0.15, hi=0.85):
316
+ groups, flat = [], []
 
 
 
 
 
317
  for text in texts:
318
+ sents = split_sentences(text) or [text]
319
+ start = len(flat)
320
+ flat.extend(sents)
321
+ groups.append((start, len(flat)))
322
+
323
+ rewritten = []
324
+ total = max(1, len(flat))
325
+ for i in range(0, len(flat), GPU_CHUNK):
326
+ if progress is not None:
327
+ progress(lo + (hi - lo) * i / total, desc=f"Paraphrasing sentences {i + 1}–{min(i + GPU_CHUNK, total)} of {total}")
328
+ rewritten.extend(paraphrase_batch(flat[i:i + GPU_CHUNK], temperature))
329
+
330
+ return [" ".join(rewritten[s:e]) for s, e in groups]
331
+
332
+
333
+ def _preview(pairs, limit=10):
334
+ blocks = []
335
+ for i, (before, after) in enumerate(pairs[:limit], 1):
336
+ blocks.append(
337
+ f"#### Change {i}\n**Original:** {before}\n\n**Paraphrased:** {after}"
338
+ )
339
+ return "\n\n---\n\n".join(blocks) if blocks else "No textual changes were generated."
340
 
341
 
342
+ # ---------------------------------------------------------------------------
343
+ # Handlers
344
+ # ---------------------------------------------------------------------------
345
  def process_docx(file_path, temperature, min_words, progress=gr.Progress()):
346
  if not file_path:
347
  raise gr.Error("Please upload a .docx file.")
 
348
  source_path = Path(file_path)
349
  if source_path.suffix.lower() != ".docx":
350
  raise gr.Error("Only .docx Word documents are supported.")
351
 
352
  progress(0.05, desc="Opening Word document")
 
353
  doc = Document(str(source_path))
354
 
355
+ targets, originals = [], []
 
 
356
  for paragraph in collect_paragraphs(doc):
357
  text = paragraph.text
358
  if should_process(text, min_words):
 
362
  if not originals:
363
  raise gr.Error("No eligible prose paragraphs were found in the document.")
364
 
365
+ revised = paraphrase_paragraphs(originals, temperature, progress)
 
 
 
 
 
 
 
366
 
367
+ progress(0.9, desc="Injecting revised text into the original layout")
368
+ changed, pairs, final_after = 0, [], []
369
  for paragraph, before, after in zip(targets, originals, revised):
370
+ after = (after or "").strip()
371
+ if after and after != before.strip():
372
+ replace_text_preserving_paragraph(paragraph, after)
373
  changed += 1
374
+ pairs.append((before, after))
375
+ final_after.append(after)
376
+ else:
377
+ final_after.append(before)
378
 
379
+ fd, output_path = tempfile.mkstemp(prefix=f"{source_path.stem}_paraphrased_", suffix=".docx")
 
 
 
 
 
 
 
 
 
 
380
  os.close(fd)
381
  doc.save(output_path)
 
382
  progress(1.0, desc="Completed")
383
 
384
  summary = (
385
+ f"✅ **Completed** — {changed} of {len(originals)} eligible paragraphs rewritten. "
386
+ f"Layout, styles, tables, images and headers/footers were kept in place."
 
 
387
  )
388
+ scores = score_panel("\n".join(originals), "\n".join(final_after))
389
+ return output_path, summary, scores, _preview(pairs)
390
 
 
 
 
391
 
392
+ def process_text(text, temperature, min_words, progress=gr.Progress()):
393
+ text = (text or "").strip()
394
+ if not text:
395
+ raise gr.Error("Please paste some text to paraphrase.")
396
+ n_words = len(text.split())
397
+ if n_words > MAX_PASTE_WORDS:
398
+ raise gr.Error(
399
+ f"Pasted text has {n_words} words. Please keep it under {MAX_PASTE_WORDS} words, "
400
+ "or upload it as a .docx instead."
401
+ )
402
 
403
+ progress(0.05, desc="Reading pasted text")
404
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n|\r?\n", text)]
405
+ idx = [i for i, p in enumerate(paragraphs) if should_process(p, min_words)]
406
+ if not idx:
407
+ raise gr.Error(f"No paragraph has at least {int(min_words)} words of prose. Lower the minimum and try again.")
408
+
409
+ revised = paraphrase_paragraphs([paragraphs[i] for i in idx], temperature, progress)
410
+ result = list(paragraphs)
411
+ pairs = []
412
+ for i, after in zip(idx, revised):
413
+ after = (after or "").strip()
414
+ if after and after != paragraphs[i]:
415
+ result[i] = after
416
+ pairs.append((paragraphs[i], after))
417
+
418
+ output_text = "\n\n".join(p for p in result if p)
419
+
420
+ progress(0.95, desc="Preparing download")
421
+ doc = Document()
422
+ for p in result:
423
+ if p:
424
+ doc.add_paragraph(p)
425
+ fd, output_path = tempfile.mkstemp(prefix="paraphrased_text_", suffix=".docx")
426
+ os.close(fd)
427
+ doc.save(output_path)
428
+ progress(1.0, desc="Completed")
429
 
430
+ summary = f"✅ **Completed** — {len(pairs)} of {len(idx)} eligible paragraphs rewritten ({n_words} words in)."
431
+ scores = score_panel(
432
+ "\n".join(paragraphs[i] for i in idx),
433
+ "\n".join(result[i] for i in idx),
434
+ )
435
+ return output_text, output_path, summary, scores
436
+
437
+
438
+ def count_words(text):
439
+ n = len((text or "").split())
440
+ color = BRAND["red"] if n > MAX_PASTE_WORDS else BRAND["grey"]
441
+ return f"<div class='cs-count' style='color:{color}'>{n:,} / {MAX_PASTE_WORDS:,} words</div>"
442
+
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # UI
446
+ # ---------------------------------------------------------------------------
447
+ CSS = f"""
448
+ :root {{
449
+ --cs-navy:{BRAND['navy']}; --cs-purple:{BRAND['purple']}; --cs-magenta:{BRAND['magenta']};
450
+ --cs-sky:{BRAND['sky']}; --cs-rainbow:{RAINBOW};
451
+ }}
452
+ .gradio-container {{ max-width: 1180px !important; margin: 0 auto !important;
453
+ font-family: 'Poppins', ui-sans-serif, system-ui, sans-serif !important; }}
454
+ body, .gradio-container {{ background: #f6f7fb !important; }}
455
+
456
+ .cs-header {{ display:flex; align-items:center; gap:22px; padding:22px 26px; border-radius:20px;
457
+ background:#fff; box-shadow:0 8px 30px rgba(7,34,139,.08); position:relative; overflow:hidden; }}
458
+ .cs-header::after {{ content:""; position:absolute; left:0; right:0; bottom:0; height:5px; background:var(--cs-rainbow); }}
459
+ .cs-header img {{ width:92px; height:92px; border-radius:50%; flex:none;
460
+ box-shadow:0 4px 14px rgba(0,0,0,.12); }}
461
+ .cs-header h1 {{ margin:0; font-size:30px; font-weight:800; color:var(--cs-navy); letter-spacing:-.3px; line-height:1.15; }}
462
+ .cs-header h1 span {{ color:var(--cs-magenta) !important; }}
463
+ .cs-header p {{ margin:6px 0 0; color:#555; font-size:14.5px; }}
464
+ .cs-tag {{ margin-top:8px; font-size:11.5px; letter-spacing:3px; color:#444; font-weight:600; }}
465
+ .cs-tag b {{ color:var(--cs-magenta); }} .cs-tag i {{ color:var(--cs-navy); font-style:normal; }}
466
+ @media (max-width:640px) {{ .cs-header {{ flex-direction:column; text-align:center; }} .cs-header h1 {{ font-size:24px; }} }}
467
+
468
+ .cs-card {{ background:#fff !important; border-radius:18px !important; padding:18px !important;
469
+ box-shadow:0 6px 24px rgba(7,34,139,.06) !important; border:1px solid #eceef6 !important; }}
470
+
471
+ button.primary, .cs-go {{ background:linear-gradient(90deg,var(--cs-navy),var(--cs-purple) 60%,var(--cs-magenta)) !important;
472
+ color:#fff !important; border:none !important; font-weight:700 !important; font-size:16px !important;
473
+ border-radius:12px !important; box-shadow:0 6px 18px rgba(71,20,139,.28) !important; }}
474
+ button.primary:hover {{ filter:brightness(1.08); transform:translateY(-1px); }}
475
+
476
+ .tabs button[role="tab"] {{ font-weight:600 !important; font-size:15px !important; }}
477
+ .tabs button[role="tab"].selected {{ color:var(--cs-navy) !important; border-color:var(--cs-magenta) !important; }}
478
+
479
+ .cs-scores {{ display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }}
480
+ @media (max-width:820px) {{ .cs-scores {{ grid-template-columns:repeat(2,1fr); }} }}
481
+ .cs-scores.cs-empty {{ display:block; text-align:center; color:#888; padding:26px; border:2px dashed #e3e6f2; border-radius:16px; }}
482
+ .cs-gauge {{ background:#fff; border:1px solid #eceef6; border-radius:16px; padding:16px 10px; text-align:center;
483
+ box-shadow:0 4px 16px rgba(7,34,139,.05); }}
484
+ .cs-ring {{ width:104px; height:104px; margin:0 auto 10px; border-radius:50%;
485
+ background:conic-gradient(var(--c) calc(var(--p)*1%), #edf0f7 0); display:grid; place-items:center; }}
486
+ .cs-ring-inner {{ width:80px; height:80px; border-radius:50%; background:#fff; display:grid; place-items:center; }}
487
+ .cs-ring-inner span {{ font-size:22px; font-weight:800; color:#222; }}
488
+ .cs-gauge-title {{ font-weight:700; color:var(--cs-navy); font-size:14px; }}
489
+ .cs-chip {{ display:inline-block; margin:6px 0; padding:2px 12px; border-radius:99px; font-size:12px; font-weight:700; }}
490
+ .cs-gauge-cap {{ font-size:11.5px; color:#777; line-height:1.35; }}
491
+ .cs-disclaimer {{ margin-top:12px; font-size:12px; color:#666; background:#fff8ec; border-left:4px solid {BRAND['orange']};
492
+ padding:10px 14px; border-radius:10px; }}
493
+ .cs-count {{ text-align:right; font-size:12px; margin-top:-6px; }}
494
+
495
+ .cs-footer {{ text-align:center; margin-top:18px; padding:16px; color:#666; font-size:13px; }}
496
+ .cs-footer .bar {{ height:3px; width:220px; margin:0 auto 12px; border-radius:3px; background:var(--cs-rainbow); }}
497
+ .cs-footer a {{ color:var(--cs-navy); font-weight:700; text-decoration:none; }}
498
+ footer {{ display:none !important; }}
499
+ """
500
 
501
+ FORCE_LIGHT = """
502
+ () => {
503
+ const url = new URL(window.location);
504
+ if (url.searchParams.get('__theme') !== 'light') {
505
+ url.searchParams.set('__theme', 'light');
506
+ window.location.href = url.href;
507
+ }
508
+ }
509
+ """
510
 
511
+ logo_html = f'<img src="{LOGO_SRC}" alt="CS Software Solutions logo">' if LOGO_SRC else ""
512
+ HEADER = f"""
513
+ <div class="cs-header">
514
+ {logo_html}
515
+ <div>
516
+ <h1>{APP_NAME.replace("Paraphraser", "<span>Paraphraser</span>")}</h1>
517
+ <p>Rewrite academic prose while keeping your Word layout intact — upload a <b>.docx</b> or paste text,
518
+ then review tentative similarity and AI-style indicators.</p>
519
+ <div class="cs-tag">INNOVATE <b>•</b> DEVELOP <i>•</i> DELIVER</div>
520
+ </div>
521
+ </div>
522
+ """
523
 
524
+ FOOTER = f"""
525
+ <div class="cs-footer">
526
+ <div class="bar"></div>
527
+ © CS Software Solutions · Tri-Crest Press &nbsp;|&nbsp; <a href="{SITE_URL}" target="_blank">cssoftwaresolutions.in</a><br>
528
+ Paraphrased output should be checked for accuracy and properly cited before submission.
529
+ </div>
530
  """
 
531
 
532
+ theme = gr.themes.Soft(
533
+ primary_hue="indigo",
534
+ secondary_hue="pink",
535
+ neutral_hue="slate",
536
+ font=[gr.themes.GoogleFont("Poppins"), "ui-sans-serif", "sans-serif"],
537
+ radius_size="lg",
538
+ )
539
+
540
+ with gr.Blocks(title=f"{APP_NAME} · CS Software Solutions", theme=theme, css=CSS, js=FORCE_LIGHT) as demo:
541
+ gr.HTML(HEADER)
542
+
543
+ with gr.Tabs():
544
+ # ---------------- Upload DOCX ----------------
545
+ with gr.Tab("📄 Upload Word (.docx)"):
546
+ with gr.Row(equal_height=False):
547
+ with gr.Column(scale=5, elem_classes="cs-card"):
548
+ input_file = gr.File(label="Word document (.docx)", file_types=[".docx"], type="filepath")
549
+ with gr.Accordion("Settings", open=False):
550
+ d_temp = gr.Slider(0.3, 1.5, value=0.9, step=0.1, label="Paraphrase variation",
551
+ info="Higher = bolder rewording, lower = closer to the original")
552
+ d_min = gr.Slider(5, 30, value=8, step=1, label="Minimum words per paragraph",
553
+ info="Shorter lines (headings, captions) are left untouched")
554
+ d_btn = gr.Button("✨ Paraphrase document", variant="primary", size="lg")
555
+ gr.Markdown(
556
+ "<small>Keeps sections, margins, styles, tables, images, numbering and headers/footers. "
557
+ "Mixed bold/italic inside one paragraph takes the first run's formatting.</small>"
558
+ )
559
+ with gr.Column(scale=5, elem_classes="cs-card"):
560
+ d_status = gr.Markdown("Upload a document and press **Paraphrase document**.")
561
+ d_out = gr.File(label="Download paraphrased .docx", interactive=False)
562
+ d_scores = gr.HTML(EMPTY_SCORES)
563
+ with gr.Accordion("Preview of changes (first 10)", open=False):
564
+ d_preview = gr.Markdown()
565
+
566
+ # ---------------- Paste text ----------------
567
+ with gr.Tab("✍️ Paste text"):
568
+ with gr.Row(equal_height=True):
569
+ with gr.Column(scale=1, elem_classes="cs-card"):
570
+ p_in = gr.Textbox(label="Your text", lines=16, max_lines=30,
571
+ placeholder="Paste your paragraphs here. Separate paragraphs with a blank line.")
572
+ p_count = gr.HTML(count_words(""))
573
+ with gr.Column(scale=1, elem_classes="cs-card"):
574
+ p_out = gr.Textbox(label="Paraphrased text", lines=16, max_lines=30,
575
+ show_copy_button=True, interactive=False)
576
+ with gr.Row():
577
+ with gr.Column(scale=5):
578
+ with gr.Accordion("Settings", open=False):
579
+ p_temp = gr.Slider(0.3, 1.5, value=0.9, step=0.1, label="Paraphrase variation")
580
+ p_min = gr.Slider(3, 30, value=6, step=1, label="Minimum words per paragraph")
581
+ p_btn = gr.Button("✨ Paraphrase text", variant="primary", size="lg")
582
+ with gr.Column(scale=5):
583
+ p_status = gr.Markdown()
584
+ p_file = gr.File(label="Download as .docx", interactive=False)
585
+ p_scores = gr.HTML(EMPTY_SCORES)
586
+
587
+ gr.HTML(FOOTER)
588
+
589
+ d_btn.click(
590
+ process_docx,
591
+ inputs=[input_file, d_temp, d_min],
592
+ outputs=[d_out, d_status, d_scores, d_preview],
593
  api_name="paraphrase_docx",
594
  )
595
+ p_in.change(count_words, inputs=p_in, outputs=p_count, show_progress="hidden", queue=False)
596
+ p_btn.click(
597
+ process_text,
598
+ inputs=[p_in, p_temp, p_min],
599
+ outputs=[p_out, p_file, p_status, p_scores],
600
+ api_name="paraphrase_text",
601
+ )
602
+
603
+ demo.queue(default_concurrency_limit=2, max_size=30)
604
 
 
 
605
  if __name__ == "__main__":
606
  demo.launch()
logo.png ADDED
requirements.txt CHANGED
@@ -1,3 +1,4 @@
 
1
  transformers>=4.45,<5
2
  sentencepiece>=0.2
3
  protobuf==3.20.3
 
1
+ torch
2
  transformers>=4.45,<5
3
  sentencepiece>=0.2
4
  protobuf==3.20.3