parrot-api / app.py
Rasheed's picture
Upload app.py
ff8c3c8 verified
Raw History Blame Contribute Delete
49.4 kB
import base64
import hashlib
import hmac
import secrets
import sqlite3
import os
import re
import statistics
import tempfile
import threading
import time
from pathlib import Path
import gradio as gr
import spaces
import torch
from docx import Document
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
# ---------------------------------------------------------------------------
# Brand (colours sampled from the CS Software Solutions logo)
# ---------------------------------------------------------------------------
BRAND = {
"navy": "#07228B",
"purple": "#47148B",
"magenta": "#E92E7F",
"red": "#E12B41",
"orange": "#FE5828",
"yellow": "#F5C518",
"green": "#50BF49",
"sky": "#50B4EF",
"grey": "#4A4A4A",
}
RAINBOW = (
f"linear-gradient(90deg, {BRAND['purple']}, {BRAND['navy']}, {BRAND['sky']}, "
f"{BRAND['green']}, {BRAND['yellow']}, {BRAND['orange']}, {BRAND['red']}, {BRAND['magenta']})"
)
APP_NAME = "CS Academic Paraphraser"
SITE_URL = "https://cssoftwaresolutions.in"
LOGO_PATH = Path(__file__).parent / "logo.png"
LOGO_SRC = ""
FAVICON_PATH = Path(__file__).parent / "favicon.png"
FAVICON_SRC = ""
if FAVICON_PATH.exists():
FAVICON_SRC = "data:image/png;base64," + base64.b64encode(FAVICON_PATH.read_bytes()).decode()
if LOGO_PATH.exists():
LOGO_SRC = "data:image/png;base64," + base64.b64encode(LOGO_PATH.read_bytes()).decode()
# ---------------------------------------------------------------------------
# Model (kept in CPU RAM while idle; moved to GPU only inside @spaces.GPU)
# ---------------------------------------------------------------------------
MODEL_ID = os.getenv("PARROT_MODEL", "prithivida/parrot_paraphraser_on_T5")
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_ID).eval()
model_lock = threading.Lock()
GPU_CHUNK = 48 # sentences per ZeroGPU allocation (short reservations)
CPU_CHUNK = 16 # sentences per CPU step (keeps progress updating)
MODES = ["Auto (GPU, falls back to CPU)", "CPU only (no GPU quota)"]
MAX_PASTE_WORDS = 6000 # keeps a single paste request within quota
# ---------------------------------------------------------------------------
# Text helpers
# ---------------------------------------------------------------------------
def split_sentences(text):
parts = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9\"'(\[])", text.strip())
return [p.strip() for p in parts if p.strip()]
def should_process(text, min_words):
text = (text or "").strip()
if len(text.split()) < int(min_words):
return False
if re.fullmatch(r"https?://\S+|www\.\S+|\S+@\S+\.\S+", text):
return False
alpha = sum(ch.isalpha() for ch in text)
if alpha < max(5, int(len(text) * 0.35)):
return False
return True
def words(text):
return re.findall(r"[A-Za-z0-9']+", (text or "").lower())
# ---------------------------------------------------------------------------
# Tentative indicators (local heuristics — NOT Turnitin / iThenticate / GPTZero)
# ---------------------------------------------------------------------------
def _ngrams(tokens, n):
return {tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)}
def similarity_to_source(original, revised):
"""Share of the revised text's word n-grams that also occur in the original."""
a, b = words(original), words(revised)
if len(b) < 4 or len(a) < 4:
return 0.0
scores, weights = [], []
for n, w in ((2, 0.2), (3, 0.35), (4, 0.45)):
gb = _ngrams(b, n)
if not gb:
continue
scores.append(len(gb & _ngrams(a, n)) / len(gb))
weights.append(w)
if not weights:
return 0.0
return 100.0 * sum(s * w for s, w in zip(scores, weights)) / sum(weights)
AI_MARKERS = [
"delve", "delves", "delving", "furthermore", "moreover", "additionally",
"in conclusion", "it is important to note", "it is worth noting", "plays a crucial role",
"plays a pivotal role", "pivotal", "crucial", "comprehensive", "leverage", "leveraging",
"seamless", "seamlessly", "robust", "landscape", "underscore", "underscores",
"notably", "in today's", "fostering", "foster", "multifaceted", "intricate",
"realm", "tapestry", "navigate", "navigating", "holistic", "paramount",
"a testament to", "shed light", "sheds light", "ever-evolving", "cutting-edge",
"in the realm of", "overall,", "ultimately", "enhance", "enhancing", "streamline",
"valuable insights", "key role", "vital role", "significantly",
]
def ai_likelihood(text):
"""Stylometric estimate (0–100) of machine-like writing."""
toks = words(text)
if len(toks) < 30:
return None
sents = split_sentences(text) or [text]
lengths = [len(words(s)) for s in sents if words(s)]
# 1. Burstiness: humans vary sentence length more than LLMs.
if len(lengths) >= 3:
cv = statistics.pstdev(lengths) / max(1.0, statistics.mean(lengths))
burst = min(1.0, max(0.0, (0.60 - cv) / 0.45))
else:
burst = 0.5
# 2. Density of stock "LLM" vocabulary per 100 words.
low = " " + " ".join(toks) + " "
raw = (text or "").lower()
hits = sum(raw.count(m) if " " in m or "," in m else low.count(f" {m} ") for m in AI_MARKERS)
marker = min(1.0, (hits / len(toks) * 100) / 2.5)
# 3. Uniformity: mid-length sentences, repeated openers, low lexical variety.
mean_len = statistics.mean(lengths)
band = 1.0 if 16 <= mean_len <= 28 else 0.4
openers = [words(s)[0] for s in sents if words(s)]
repeat = 1 - len(set(openers)) / len(openers) if openers else 0
window = toks[:400]
ttr = len(set(window)) / len(window)
variety = min(1.0, max(0.0, (0.62 - ttr) / 0.25))
uniform = 0.45 * band + 0.3 * min(1.0, repeat * 2) + 0.25 * variety
score = 100 * (0.40 * burst + 0.35 * marker + 0.25 * uniform)
return float(max(3.0, min(97.0, score)))
def words_changed(original, revised):
a, b = words(original), words(revised)
if not b:
return 0.0
return 100.0 * (1 - len(set(b) & set(a)) / len(set(b)))
def _level(value, invert=False):
if value is None:
return "#9aa0a6", "n/a"
v = 100 - value if invert else value
if v < 30:
return BRAND["green"], "Low"
if v < 55:
return BRAND["orange"], "Moderate"
return BRAND["red"], "High"
def _gauge(title, value, caption, invert=False, fixed_color=None):
color, label = _level(value, invert)
if fixed_color:
color = fixed_color
label = "Rewrite depth"
pct = 0 if value is None else round(value)
shown = "—" if value is None else f"{pct}%"
return f"""
<div class="cs-gauge">
<div class="cs-ring" style="--p:{pct};--c:{color}">
<div class="cs-ring-inner"><span>{shown}</span></div>
</div>
<div class="cs-gauge-title">{title}</div>
<div class="cs-chip" style="background:{color}1f;color:{color}">{label}</div>
<div class="cs-gauge-cap">{caption}</div>
</div>"""
def score_panel(original, revised):
sim = similarity_to_source(original, revised)
ai_in = ai_likelihood(original)
ai_out = ai_likelihood(revised)
changed = words_changed(original, revised)
return f"""
<div class="cs-scores">
{_gauge("Similarity to source", sim, "Word n-gram overlap with your original")}
{_gauge("AI indicator · input", ai_in, "Stylometric estimate of the original")}
{_gauge("AI indicator · output", ai_out, "Stylometric estimate after paraphrasing")}
{_gauge("Vocabulary changed", changed, "Share of new wording", fixed_color=BRAND["navy"])}
</div>
<div class="cs-disclaimer">
<b>Tentative indicators only.</b> Similarity is measured against your own original text,
not against internet or journal databases, and the AI indicator is a stylometric heuristic.
Results will differ from Turnitin, iThenticate or other detectors. Always review the
output for meaning, technical terms and citations before use.
</div>"""
EMPTY_SCORES = """
<div class="cs-scores cs-empty">
<div>Scores appear here after paraphrasing — similarity to source, AI indicators and rewrite depth.</div>
</div>"""
# ---------------------------------------------------------------------------
# DOCX structure helpers
# ---------------------------------------------------------------------------
def iter_table_paragraphs(table):
for row in table.rows:
for cell in row.cells:
for paragraph in cell.paragraphs:
yield paragraph
for nested_table in cell.tables:
yield from iter_table_paragraphs(nested_table)
def collect_paragraphs(doc):
seen = set()
def unique(paragraphs):
for paragraph in paragraphs:
key = id(paragraph._p)
if key not in seen:
seen.add(key)
yield paragraph
yield from unique(doc.paragraphs)
for table in doc.tables:
yield from unique(iter_table_paragraphs(table))
for section in doc.sections:
for part in (
section.header, section.footer,
section.first_page_header, section.first_page_footer,
section.even_page_header, section.even_page_footer,
):
yield from unique(part.paragraphs)
for table in part.tables:
yield from unique(iter_table_paragraphs(table))
def _is_protected(run):
return bool(
run._r.xpath(".//w:drawing")
or run._r.xpath(".//w:object")
or run._r.xpath(".//w:fldChar")
)
def replace_text_preserving_paragraph(paragraph, new_text):
runs = list(paragraph.runs)
if not runs:
paragraph.add_run(new_text)
return
target = next((r for r in runs if not _is_protected(r)), None)
if target is None:
target = paragraph.add_run()
target.text = new_text
for run in runs:
if run is not target and not _is_protected(run):
run.text = ""
# ---------------------------------------------------------------------------
# Paraphrasing
# ---------------------------------------------------------------------------
def _generate(texts, temperature, device, batch_size):
temperature = max(0.3, min(float(temperature), 1.5))
outputs = []
with torch.inference_mode():
for start in range(0, len(texts), batch_size):
chunk = texts[start:start + batch_size]
encoded = tokenizer(
["paraphrase: " + t for t in chunk],
return_tensors="pt", padding=True, truncation=True, max_length=256,
)
encoded = {k: v.to(device) for k, v in encoded.items()}
generated = model.generate(
**encoded,
do_sample=True,
temperature=temperature,
top_p=0.92,
num_return_sequences=1,
max_new_tokens=180,
no_repeat_ngram_size=3,
)
decoded = tokenizer.batch_decode(generated.detach().cpu(), skip_special_tokens=True)
outputs.extend(c.strip() if c.strip() else s for s, c in zip(chunk, decoded))
del encoded, generated
return outputs
def _gpu_duration(texts, temperature):
# Reserve only what this chunk needs, so visitors' daily ZeroGPU quota lasts longer.
return int(min(90, 12 + 0.3 * len(texts or [])))
@spaces.GPU(duration=_gpu_duration)
def paraphrase_batch_gpu(texts, temperature):
if not texts:
return []
if not torch.cuda.is_available():
raise RuntimeError("GPU allocation did not expose CUDA.")
with model_lock:
model.to("cuda")
try:
return _generate(texts, temperature, "cuda", batch_size=16)
finally:
model.to("cpu")
torch.cuda.empty_cache()
def paraphrase_batch_cpu(texts, temperature):
if not texts:
return []
with model_lock:
model.to("cpu")
return _generate(texts, temperature, "cpu", batch_size=4)
def _is_gpu_unavailable(err):
msg = str(err).lower()
return any(k in msg for k in ("quota", "zerogpu", "gpu task aborted", "cuda", "no gpu", "queue is full"))
def paraphrase_paragraphs(texts, temperature, progress=None, lo=0.15, hi=0.85, mode="Auto"):
"""Returns (paragraphs, engine_note)."""
groups, flat = [], []
for text in texts:
sents = split_sentences(text) or [text]
start = len(flat)
flat.extend(sents)
groups.append((start, len(flat)))
use_gpu = not str(mode).lower().startswith("cpu")
fell_back = False
rewritten = []
total = max(1, len(flat))
step = GPU_CHUNK if use_gpu else CPU_CHUNK
i = 0
while i < len(flat):
chunk = flat[i:i + step]
engine = "GPU" if use_gpu else "CPU"
if progress is not None:
progress(lo + (hi - lo) * i / total,
desc=f"Paraphrasing sentences {i + 1}–{i + len(chunk)} of {total} ({engine})")
if use_gpu:
try:
rewritten.extend(paraphrase_batch_gpu(chunk, temperature))
except Exception as err:
if not _is_gpu_unavailable(err):
raise
use_gpu, fell_back, step = False, True, CPU_CHUNK
continue # retry the same chunk on CPU
else:
rewritten.extend(paraphrase_batch_cpu(chunk, temperature))
i += len(chunk)
if fell_back:
note = "⚠️ GPU quota was unavailable, so the remaining text was processed on CPU (slower, same quality)."
elif not str(mode).lower().startswith("cpu"):
note = "⚡ Processed on GPU."
else:
note = "🖥️ Processed on CPU."
return [" ".join(rewritten[s:e]) for s, e in groups], note
def _preview(pairs, limit=10):
blocks = []
for i, (before, after) in enumerate(pairs[:limit], 1):
blocks.append(
f"#### Change {i}\n**Original:** {before}\n\n**Paraphrased:** {after}"
)
return "\n\n---\n\n".join(blocks) if blocks else "No textual changes were generated."
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def _docx_core(file_path, temperature, min_words, mode, progress):
if not file_path:
raise gr.Error("Please upload a .docx file.")
source_path = Path(file_path)
if source_path.suffix.lower() != ".docx":
raise gr.Error("Only .docx Word documents are supported.")
progress(0.05, desc="Opening Word document")
doc = Document(str(source_path))
targets, originals = [], []
for paragraph in collect_paragraphs(doc):
text = paragraph.text
if should_process(text, min_words):
targets.append(paragraph)
originals.append(text)
if not originals:
raise gr.Error("No eligible prose paragraphs were found in the document.")
revised, engine_note = paraphrase_paragraphs(originals, temperature, progress, mode=mode)
progress(0.9, desc="Injecting revised text into the original layout")
changed, pairs, final_after = 0, [], []
for paragraph, before, after in zip(targets, originals, revised):
after = (after or "").strip()
if after and after != before.strip():
replace_text_preserving_paragraph(paragraph, after)
changed += 1
pairs.append((before, after))
final_after.append(after)
else:
final_after.append(before)
output_path = _save_output(doc, source_path.stem)
progress(1.0, desc="Completed")
summary = (
f"✅ **Completed** — {changed} of {len(originals)} eligible paragraphs rewritten. "
f"Layout, styles, tables, images and headers/footers were kept in place.\n\n{engine_note}"
)
scores = score_panel("\n".join(originals), "\n".join(final_after))
return output_path, download_button(output_path), summary, scores, _preview(pairs)
def _text_core(text, temperature, min_words, mode, progress):
text = (text or "").strip()
if not text:
raise gr.Error("Please paste some text to paraphrase.")
n_words = len(text.split())
if n_words > MAX_PASTE_WORDS:
raise gr.Error(
f"Pasted text has {n_words} words. Please keep it under {MAX_PASTE_WORDS} words, "
"or upload it as a .docx instead."
)
progress(0.05, desc="Reading pasted text")
paragraphs = [p.strip() for p in re.split(r"\n\s*\n|\r?\n", text)]
idx = [i for i, p in enumerate(paragraphs) if should_process(p, min_words)]
if not idx:
raise gr.Error(f"No paragraph has at least {int(min_words)} words of prose. Lower the minimum and try again.")
revised, engine_note = paraphrase_paragraphs([paragraphs[i] for i in idx], temperature, progress, mode=mode)
result = list(paragraphs)
pairs = []
for i, after in zip(idx, revised):
after = (after or "").strip()
if after and after != paragraphs[i]:
result[i] = after
pairs.append((paragraphs[i], after))
output_text = "\n\n".join(p for p in result if p)
progress(0.95, desc="Preparing download")
doc = Document()
for p in result:
if p:
doc.add_paragraph(p)
output_path = _save_output(doc, "pasted_text")
progress(1.0, desc="Completed")
summary = f"✅ **Completed** — {len(pairs)} of {len(idx)} eligible paragraphs rewritten ({n_words} words in).\n\n{engine_note}"
scores = score_panel(
"\n".join(paragraphs[i] for i in idx),
"\n".join(result[i] for i in idx),
)
return output_text, output_path, download_button(output_path), summary, scores
# ---------------------------------------------------------------------------
# Accounts (email + password, PBKDF2-hashed, SQLite)
# Stored in /data when persistent storage is enabled; otherwise next to app.py.
# ---------------------------------------------------------------------------
_DATA_DIR = Path("/data") if Path("/data").is_dir() and os.access("/data", os.W_OK) else Path(__file__).parent
USERS_DB = Path(os.getenv("USERS_DB", str(_DATA_DIR / "users.db")))
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _db():
con = sqlite3.connect(str(USERS_DB), timeout=10)
con.execute(
"CREATE TABLE IF NOT EXISTS users (email TEXT PRIMARY KEY, name TEXT, salt TEXT, "
"pw TEXT, created TEXT DEFAULT CURRENT_TIMESTAMP, last_login TEXT, runs INTEGER DEFAULT 0)"
)
cols = {r[1] for r in con.execute("PRAGMA table_info(users)")}
if "active" not in cols:
con.execute("ALTER TABLE users ADD COLUMN active INTEGER DEFAULT 1")
return con
# Admins: comma-separated emails in the ADMIN_EMAILS secret (Space settings → Variables and secrets).
ADMIN_EMAILS = {e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()}
def is_admin(user):
return bool(user and user.get("email", "").lower() in ADMIN_EMAILS)
def _hash(password, salt):
return hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), 200_000).hex()
def create_user(name, email, password, confirm):
name, email = (name or "").strip(), (email or "").strip().lower()
if not name:
return None, "Please enter your name."
if not _EMAIL_RE.match(email):
return None, "Please enter a valid email address."
if len(password or "") < 8:
return None, "Password must be at least 8 characters."
if password != confirm:
return None, "Passwords do not match."
salt = secrets.token_hex(16)
with _db() as con:
if con.execute("SELECT 1 FROM users WHERE email=?", (email,)).fetchone():
return None, "An account with this email already exists. Please log in."
con.execute("INSERT INTO users (email, name, salt, pw, last_login) VALUES (?,?,?,?,CURRENT_TIMESTAMP)",
(email, name, salt, _hash(password, salt)))
return {"email": email, "name": name}, None
def check_user(email, password):
email = (email or "").strip().lower()
with _db() as con:
row = con.execute("SELECT name, salt, pw, active FROM users WHERE email=?", (email,)).fetchone()
if not row or not hmac.compare_digest(row[2], _hash(password or "", row[1])):
return None, "Incorrect email or password."
if row[3] == 0:
return None, "This account has been disabled. Please contact CS Software Solutions."
con.execute("UPDATE users SET last_login=CURRENT_TIMESTAMP WHERE email=?", (email,))
return {"email": email, "name": row[0]}, None
def _count_run(user):
try:
with _db() as con:
con.execute("UPDATE users SET runs = runs + 1 WHERE email=?", (user["email"],))
except Exception:
pass
def _require(user):
if not user or not user.get("email"):
raise gr.Error("Please log in to use the paraphraser.")
with _db() as con:
row = con.execute("SELECT active FROM users WHERE email=?", (user["email"],)).fetchone()
if not row or row[0] == 0:
raise gr.Error("This account has been disabled.")
def _msg(text, ok=False):
cls = "cs-ok" if ok else "cs-bad"
return f'<div class="cs-authmsg {cls}">{text}</div>' if text else ""
def _welcome(user):
return (f'<div class="cs-userbar"><span>👋 Signed in as <b>{user["name"]}</b> '
f'<small>({user["email"]})</small></span></div>')
def _signed_in(user):
admin = is_admin(user)
return (user, gr.update(visible=False), gr.update(visible=True), _welcome(user), "",
gr.update(visible=admin), admin_stats(user) if admin else "", admin_table(user) if admin else None)
def do_login(email, password):
user, err = check_user(email, password)
if err:
return None, gr.update(), gr.update(), "", _msg(err), gr.update(visible=False), "", None
return _signed_in(user)
def do_signup(name, email, password, confirm):
user, err = create_user(name, email, password, confirm)
if err:
return None, gr.update(), gr.update(), "", _msg(err), gr.update(visible=False), "", None
return _signed_in(user)
def do_logout():
return (None, gr.update(visible=True), gr.update(visible=False), "",
_msg("You have been logged out.", ok=True), gr.update(visible=False), "", None)
# ---------------------------------------------------------------------------
# Admin panel
# ---------------------------------------------------------------------------
ADMIN_COLS = ["Name", "Email", "Joined", "Last login", "Runs", "Status", "Role"]
def _need_admin(user):
if not is_admin(user):
raise gr.Error("Admin access only.")
def admin_stats(user):
_need_admin(user)
with _db() as con:
total = con.execute("SELECT COUNT(*) FROM users").fetchone()[0]
active = con.execute("SELECT COUNT(*) FROM users WHERE active=1").fetchone()[0]
runs = con.execute("SELECT COALESCE(SUM(runs),0) FROM users").fetchone()[0]
new7 = con.execute("SELECT COUNT(*) FROM users WHERE created >= datetime('now','-7 days')").fetchone()[0]
seen7 = con.execute("SELECT COUNT(*) FROM users WHERE last_login >= datetime('now','-7 days')").fetchone()[0]
tiles = [("Total users", total), ("Active accounts", active), ("Total runs", runs),
("New (7 days)", new7), ("Logged in (7 days)", seen7)]
cells = "".join(f'<div class="cs-stat"><div class="n">{v:,}</div><div class="l">{k}</div></div>' for k, v in tiles)
return f'<div class="cs-stats">{cells}</div>'
def admin_table(user, query=""):
_need_admin(user)
q = f"%{(query or '').strip().lower()}%"
with _db() as con:
rows = con.execute(
"SELECT name, email, created, last_login, runs, active FROM users "
"WHERE lower(name) LIKE ? OR lower(email) LIKE ? ORDER BY created DESC", (q, q)).fetchall()
return [[n, e, (c or "")[:16], (l or "")[:16], r, "Active" if a else "Disabled",
"Admin" if e in ADMIN_EMAILS else "User"] for n, e, c, l, r, a in rows]
def _admin_refresh(user, query, note, ok=True):
return admin_stats(user), admin_table(user, query), _msg(note, ok=ok)
def admin_search(user, query):
_need_admin(user)
return admin_table(user, query)
def admin_set_active(user, email, query, active):
_need_admin(user)
email = (email or "").strip().lower()
if not email:
return _admin_refresh(user, query, "Enter the user's email first.", ok=False)
if email in ADMIN_EMAILS and not active:
return _admin_refresh(user, query, "Admin accounts can't be disabled here.", ok=False)
with _db() as con:
n = con.execute("UPDATE users SET active=? WHERE email=?", (1 if active else 0, email)).rowcount
if not n:
return _admin_refresh(user, query, f"No user found with {email}.", ok=False)
return _admin_refresh(user, query, f"{email} is now {'active' if active else 'disabled'}.")
def admin_reset_pw(user, email, new_pw, query):
_need_admin(user)
email = (email or "").strip().lower()
if len(new_pw or "") < 8:
return _admin_refresh(user, query, "New password must be at least 8 characters.", ok=False)
salt = secrets.token_hex(16)
with _db() as con:
n = con.execute("UPDATE users SET salt=?, pw=? WHERE email=?", (salt, _hash(new_pw, salt), email)).rowcount
if not n:
return _admin_refresh(user, query, f"No user found with {email}.", ok=False)
return _admin_refresh(user, query, f"Password reset for {email}. Share it with them securely.")
def admin_delete(user, email, confirm, query):
_need_admin(user)
email = (email or "").strip().lower()
if (confirm or "").strip().lower() != email or not email:
return _admin_refresh(user, query, "Type the same email in the confirm box to delete.", ok=False)
if email in ADMIN_EMAILS:
return _admin_refresh(user, query, "Admin accounts can't be deleted here.", ok=False)
with _db() as con:
n = con.execute("DELETE FROM users WHERE email=?", (email,)).rowcount
if not n:
return _admin_refresh(user, query, f"No user found with {email}.", ok=False)
return _admin_refresh(user, query, f"Deleted {email}.")
def admin_export(user):
_need_admin(user)
import csv
folder = Path(tempfile.mkdtemp(dir=OUT_DIR))
path = folder / "users.csv"
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(ADMIN_COLS)
w.writerows(admin_table(user))
return str(path)
# ---------------------------------------------------------------------------
# Single branded progress panel (replaces Gradio's per-component loaders)
# ---------------------------------------------------------------------------
class _Reporter:
def __init__(self):
self.frac, self.desc, self.start = 0.0, "Starting…", time.time()
def __call__(self, frac, desc=None):
self.frac = max(self.frac, min(1.0, float(frac)))
if desc:
self.desc = desc
def _md2html(md):
html = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", md)
return html.replace("\n\n", "<br><br>").replace("\n", "<br>")
def status_idle(msg):
return f'<div class="cs-status cs-idle">{_md2html(msg)}</div>'
def status_done(msg):
return f'<div class="cs-status cs-done">{_md2html(msg)}</div>'
def status_error(msg):
return f'<div class="cs-status cs-err">⚠️ {msg}</div>'
def status_running(rep):
pct = round(rep.frac * 100)
secs = int(time.time() - rep.start)
return f"""
<div class="cs-status cs-run">
<div class="cs-spin-logo"></div>
<div class="cs-st-text">{rep.desc}</div>
<div class="cs-bar"><div style="width:{pct}%"></div></div>
<div class="cs-st-meta">{pct}% · {secs}s</div>
</div>"""
def _run_live(core, args, n_outputs, status_index):
"""Run `core` in a thread and stream only the status panel until it finishes."""
rep = _Reporter()
box = {}
def work():
try:
box["result"] = core(*args, rep)
except Exception as err: # re-raised in the request thread
box["error"] = err
t = threading.Thread(target=work, daemon=True)
t.start()
keep = [gr.update()] * n_outputs
while t.is_alive():
frame = list(keep)
frame[status_index] = status_running(rep)
yield tuple(frame)
t.join(timeout=0.6)
if "error" in box:
err = box["error"]
frame = list(keep)
frame[status_index] = status_error(str(err) or "Something went wrong.")
yield tuple(frame)
if isinstance(err, gr.Error):
raise err
raise gr.Error(f"Processing failed: {err}")
yield box["result"]
def process_docx(file_path, temperature, min_words, mode=MODES[0], user=None):
_require(user)
_count_run(user)
if not file_path:
raise gr.Error("Please upload a .docx file.")
for frame in _run_live(_docx_core, (file_path, temperature, min_words, mode), 5, 2):
if isinstance(frame[2], str) and frame[2].lstrip().startswith("✅"):
frame = (frame[0], frame[1], status_done(frame[2]), frame[3], frame[4])
yield frame
def process_text(text, temperature, min_words, mode=MODES[0], user=None):
_require(user)
_count_run(user)
if not (text or "").strip():
raise gr.Error("Please paste some text to paraphrase.")
for frame in _run_live(_text_core, (text, temperature, min_words, mode), 5, 3):
if isinstance(frame[3], str) and frame[3].lstrip().startswith("✅"):
frame = (frame[0], frame[1], frame[2], status_done(frame[3]), frame[4])
yield frame
OUT_DIR = Path(tempfile.gettempdir()) / "cs_outputs"
OUT_DIR.mkdir(parents=True, exist_ok=True)
def _save_output(doc, stem):
"""Save to a unique folder with a clean, readable file name."""
safe = re.sub(r"[^A-Za-z0-9_-]+", "_", stem).strip("_") or "document"
folder = Path(tempfile.mkdtemp(dir=OUT_DIR))
path = folder / f"{safe}_paraphrased.docx"
doc.save(str(path))
return str(path)
def download_button(path):
"""Direct download link (works even if the file widget is blocked)."""
if not path or not Path(path).exists():
return ""
data = base64.b64encode(Path(path).read_bytes()).decode()
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
name = Path(path).name
return (f'<a class="cs-dl" download="{name}" href="data:{mime};base64,{data}">'
f'⬇️ Download {name}</a>')
def count_words(text):
n = len((text or "").split())
color = BRAND["red"] if n > MAX_PASTE_WORDS else BRAND["grey"]
return f"<div class='cs-count' style='color:{color}'>{n:,} / {MAX_PASTE_WORDS:,} words</div>"
# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
CSS = f"""
:root {{
--cs-navy:{BRAND['navy']}; --cs-purple:{BRAND['purple']}; --cs-magenta:{BRAND['magenta']};
--cs-sky:{BRAND['sky']}; --cs-rainbow:{RAINBOW};
}}
.gradio-container {{ max-width: 1180px !important; margin: 0 auto !important;
font-family: 'Poppins', ui-sans-serif, system-ui, sans-serif !important; }}
body, .gradio-container {{ background: #f6f7fb !important; }}
.cs-header {{ display:flex; align-items:center; gap:22px; padding:22px 26px; border-radius:20px;
background:#fff; box-shadow:0 8px 30px rgba(7,34,139,.08); position:relative; overflow:hidden; }}
.cs-header::after {{ content:""; position:absolute; left:0; right:0; bottom:0; height:5px; background:var(--cs-rainbow); }}
.cs-header img {{ width:92px; height:92px; border-radius:50%; flex:none;
box-shadow:0 4px 14px rgba(0,0,0,.12); }}
.cs-header h1 {{ margin:0; font-size:30px; font-weight:800; color:var(--cs-navy); letter-spacing:-.3px; line-height:1.15; }}
.cs-header h1 span {{ color:var(--cs-magenta) !important; }}
.cs-header p {{ margin:6px 0 0; color:#555; font-size:14.5px; }}
.cs-tag {{ margin-top:8px; font-size:11.5px; letter-spacing:3px; color:#444; font-weight:600; }}
.cs-tag b {{ color:var(--cs-magenta); }} .cs-tag i {{ color:var(--cs-navy); font-style:normal; }}
@media (max-width:640px) {{ .cs-header {{ flex-direction:column; text-align:center; }} .cs-header h1 {{ font-size:24px; }} }}
.cs-card {{ background:#fff !important; border-radius:18px !important; padding:18px !important;
box-shadow:0 6px 24px rgba(7,34,139,.06) !important; border:1px solid #eceef6 !important; }}
button.primary, .cs-go {{ background:linear-gradient(90deg,var(--cs-navy),var(--cs-purple) 60%,var(--cs-magenta)) !important;
color:#fff !important; border:none !important; font-weight:700 !important; font-size:16px !important;
border-radius:12px !important; box-shadow:0 6px 18px rgba(71,20,139,.28) !important; }}
button.primary:hover {{ filter:brightness(1.08); transform:translateY(-1px); }}
.tabs button[role="tab"] {{ font-weight:600 !important; font-size:15px !important; }}
.tabs button[role="tab"].selected {{ color:var(--cs-navy) !important; border-color:var(--cs-magenta) !important; }}
.cs-scores {{ display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }}
@media (max-width:820px) {{ .cs-scores {{ grid-template-columns:repeat(2,1fr); }} }}
.cs-scores.cs-empty {{ display:block; text-align:center; color:#888; padding:26px; border:2px dashed #e3e6f2; border-radius:16px; }}
.cs-gauge {{ background:#fff; border:1px solid #eceef6; border-radius:16px; padding:16px 10px; text-align:center;
box-shadow:0 4px 16px rgba(7,34,139,.05); }}
.cs-ring {{ width:104px; height:104px; margin:0 auto 10px; border-radius:50%;
background:conic-gradient(var(--c) calc(var(--p)*1%), #edf0f7 0); display:grid; place-items:center; }}
.cs-ring-inner {{ width:80px; height:80px; border-radius:50%; background:#fff; display:grid; place-items:center; }}
.cs-ring-inner span {{ font-size:22px; font-weight:800; color:#222; }}
.cs-gauge-title {{ font-weight:700; color:var(--cs-navy); font-size:14px; }}
.cs-chip {{ display:inline-block; margin:6px 0; padding:2px 12px; border-radius:99px; font-size:12px; font-weight:700; }}
.cs-gauge-cap {{ font-size:11.5px; color:#777; line-height:1.35; }}
.cs-disclaimer {{ margin-top:12px; font-size:12px; color:#666; background:#fff8ec; border-left:4px solid {BRAND['orange']};
padding:10px 14px; border-radius:10px; }}
.cs-count {{ text-align:right; font-size:12px; margin-top:-6px; }}
.cs-footer {{ text-align:center; margin-top:18px; padding:16px; color:#666; font-size:13px; }}
.cs-footer .bar {{ height:3px; width:220px; margin:0 auto 12px; border-radius:3px; background:var(--cs-rainbow); }}
.cs-footer a {{ color:var(--cs-navy); font-weight:700; text-decoration:none; }}
footer {{ display:none !important; }}
/* Custom single progress panel; Gradio's own loaders are switched off. */
.cs-status {{ padding:16px 18px; border-radius:14px; font-size:14.5px; line-height:1.5; }}
.cs-idle {{ color:#666; }}
.cs-done {{ background:#f1fbf1; border-left:4px solid {BRAND['green']}; color:#234; }}
.cs-err {{ background:#fff3f3; border-left:4px solid {BRAND['red']}; color:#8a1c1c; }}
.cs-run {{ text-align:center; background:#fff; border:1px solid #eceef6; }}
.cs-spin-logo {{ width:64px; height:64px; margin:4px auto 12px;
background:url("{FAVICON_SRC}") center/contain no-repeat; animation:cs-spin 1.4s linear infinite; }}
.cs-st-text {{ font-weight:600; color:var(--cs-navy); margin-bottom:10px; }}
.cs-bar {{ height:10px; border-radius:99px; background:#edf0f7; overflow:hidden; }}
.cs-bar > div {{ height:100%; border-radius:99px; background:var(--cs-rainbow); transition:width .5s ease; }}
.cs-st-meta {{ margin-top:8px; font-size:12px; color:#777; }}
@keyframes cs-spin {{ to {{ transform:rotate(360deg); }} }}
#huggingface-space-header, .show-api {{ display:none !important; }}
.cs-dl {{ display:block; text-align:center; margin-top:10px; padding:12px 16px; border-radius:12px; font-weight:700;
color:#fff !important; text-decoration:none !important;
background:linear-gradient(90deg,var(--cs-navy),var(--cs-purple) 60%,var(--cs-magenta)); }}
.cs-dl:hover {{ filter:brightness(1.08); }}
.cs-settings {{ background:#f8f9fd !important; border:1px solid #eceef6 !important; border-radius:14px !important;
padding:12px 14px !important; margin:10px 0 !important; }}
.cs-settings-title {{ font-weight:700; color:var(--cs-navy); font-size:15px; margin:0 0 4px; }}
.cs-stats {{ display:grid; grid-template-columns:repeat(5,1fr); gap:12px; }}
@media (max-width:820px) {{ .cs-stats {{ grid-template-columns:repeat(2,1fr); }} }}
.cs-stat {{ background:#fff; border:1px solid #eceef6; border-radius:14px; padding:14px; text-align:center;
box-shadow:0 4px 16px rgba(7,34,139,.05); }}
.cs-stat .n {{ font-size:26px; font-weight:800; color:var(--cs-navy); }}
.cs-stat .l {{ font-size:12px; color:#777; margin-top:2px; }}
.cs-auth {{ max-width:460px; margin:26px auto 0 !important; }}
.cs-auth-title {{ text-align:center; font-weight:800; font-size:22px; color:var(--cs-navy); margin:4px 0 2px; }}
.cs-auth-sub {{ text-align:center; color:#666; font-size:13.5px; margin-bottom:10px; }}
.cs-authmsg {{ padding:10px 14px; border-radius:10px; font-size:13.5px; margin-top:6px; }}
.cs-ok {{ background:#f1fbf1; color:#1e5b1e; border-left:4px solid {BRAND['green']}; }}
.cs-bad {{ background:#fff3f3; color:#8a1c1c; border-left:4px solid {BRAND['red']}; }}
.cs-userbar {{ display:flex; align-items:center; padding:10px 4px; color:#444; font-size:14px; }}
.cs-userbar small {{ color:#888; }}
.cs-logout {{ max-width:130px !important; margin-left:auto !important; }}
"""
FORCE_LIGHT = """
() => {
const url = new URL(window.location);
if (url.searchParams.get('__theme') !== 'light') {
url.searchParams.set('__theme', 'light');
window.location.href = url.href;
return;
}
// Hide the platform's floating Space badge (it can be injected after load).
const hide = () => {
const h = document.getElementById('huggingface-space-header');
if (h) h.style.setProperty('display', 'none', 'important');
};
hide();
new MutationObserver(hide).observe(document.documentElement, { childList: true, subtree: true });
// Brand favicon (replaces the platform's default tab icon).
const ICON = "__FAVICON__";
if (ICON) {
document.querySelectorAll("link[rel*='icon']").forEach(l => l.remove());
const link = document.createElement('link');
link.rel = 'icon'; link.type = 'image/png'; link.href = ICON;
document.head.appendChild(link);
}
}
"""
FORCE_LIGHT = FORCE_LIGHT.replace("__FAVICON__", FAVICON_SRC)
logo_html = f'<img src="{LOGO_SRC}" alt="CS Software Solutions logo">' if LOGO_SRC else ""
HEADER = f"""
<div class="cs-header">
{logo_html}
<div>
<h1>{APP_NAME.replace("Paraphraser", "<span>Paraphraser</span>")}</h1>
<p>Rewrite academic prose while keeping your Word layout intact — upload a <b>.docx</b> or paste text,
then review tentative similarity and AI-style indicators.</p>
<div class="cs-tag">INNOVATE <b>•</b> DEVELOP <i>•</i> DELIVER</div>
</div>
</div>
"""
FOOTER = f"""
<div class="cs-footer">
<div class="bar"></div>
© CS Software Solutions · Tri-Crest Press &nbsp;|&nbsp; <a href="{SITE_URL}" target="_blank">cssoftwaresolutions.in</a><br>
Paraphrased output should be checked for accuracy and properly cited before submission.
</div>
"""
theme = gr.themes.Soft(
primary_hue="indigo",
secondary_hue="pink",
neutral_hue="slate",
font=[gr.themes.GoogleFont("Poppins"), "ui-sans-serif", "sans-serif"],
radius_size="lg",
)
with gr.Blocks(title=f"{APP_NAME} · CS Software Solutions", theme=theme, css=CSS, js=FORCE_LIGHT) as demo:
user_state = gr.State(None)
gr.HTML(HEADER)
# ======================= Login / Sign up =======================
with gr.Column(visible=True, elem_classes="cs-auth") as auth_view:
with gr.Column(elem_classes="cs-card"):
gr.HTML('<div class="cs-auth-title">Welcome</div>'
'<div class="cs-auth-sub">Log in or create a free account to start paraphrasing.</div>')
with gr.Tabs():
with gr.Tab("🔐 Log in"):
li_email = gr.Textbox(label="Email", placeholder="you@university.edu")
li_pw = gr.Textbox(label="Password", type="password")
li_btn = gr.Button("Log in", variant="primary", size="lg")
with gr.Tab("📝 Sign up"):
su_name = gr.Textbox(label="Full name")
su_email = gr.Textbox(label="Email", placeholder="you@university.edu")
su_pw = gr.Textbox(label="Password", type="password", info="At least 8 characters")
su_pw2 = gr.Textbox(label="Confirm password", type="password")
su_btn = gr.Button("Create account", variant="primary", size="lg")
auth_msg = gr.HTML()
# ======================= Main app (after login) =======================
with gr.Column(visible=False) as app_view:
with gr.Row(equal_height=True):
user_bar = gr.HTML()
logout_btn = gr.Button("Log out", size="sm", elem_classes="cs-logout")
with gr.Tabs():
# ---------------- Upload DOCX ----------------
with gr.Tab("📄 Upload Word (.docx)"):
with gr.Row(equal_height=False):
with gr.Column(scale=5, elem_classes="cs-card"):
input_file = gr.File(label="Word document (.docx)", file_types=[".docx"], type="filepath")
with gr.Group(elem_classes="cs-settings"):
gr.HTML('<div class="cs-settings-title">⚙️ Settings</div>')
d_temp = gr.Slider(0.3, 1.5, value=0.9, step=0.1, label="Paraphrase variation",
info="Higher = bolder rewording, lower = closer to the original")
d_min = gr.Slider(5, 30, value=8, step=1, label="Minimum words per paragraph",
info="Shorter lines (headings, captions) are left untouched")
d_mode = gr.Radio(MODES, value=MODES[0], label="Processing",
info="GPU is fast but limited by a daily usage quota; CPU is slower but always available")
d_btn = gr.Button("✨ Paraphrase document", variant="primary", size="lg")
gr.Markdown(
"<small>Keeps sections, margins, styles, tables, images, numbering and headers/footers. "
"Mixed bold/italic inside one paragraph takes the first run's formatting.</small>"
)
with gr.Column(scale=5, elem_classes="cs-card"):
d_status = gr.HTML(status_idle("Upload a document and press **Paraphrase document**."))
d_out = gr.File(label="Download paraphrased .docx", interactive=False)
d_dl = gr.HTML()
d_scores = gr.HTML(EMPTY_SCORES)
with gr.Accordion("Preview of changes (first 10)", open=False):
d_preview = gr.Markdown()
# ---------------- Paste text ----------------
with gr.Tab("✍️ Paste text"):
with gr.Row(equal_height=True):
with gr.Column(scale=1, elem_classes="cs-card"):
p_in = gr.Textbox(label="Your text", lines=16, max_lines=30,
placeholder="Paste your paragraphs here. Separate paragraphs with a blank line.")
p_count = gr.HTML(count_words(""))
with gr.Column(scale=1, elem_classes="cs-card"):
p_out = gr.Textbox(label="Paraphrased text", lines=16, max_lines=30,
show_copy_button=True, interactive=False)
with gr.Row():
with gr.Column(scale=5):
with gr.Group(elem_classes="cs-settings"):
gr.HTML('<div class="cs-settings-title">⚙️ Settings</div>')
p_temp = gr.Slider(0.3, 1.5, value=0.9, step=0.1, label="Paraphrase variation",
info="Higher = bolder rewording, lower = closer to the original")
p_min = gr.Slider(3, 30, value=6, step=1, label="Minimum words per paragraph")
p_mode = gr.Radio(MODES, value=MODES[0], label="Processing")
p_btn = gr.Button("✨ Paraphrase text", variant="primary", size="lg")
with gr.Column(scale=5):
p_status = gr.HTML(status_idle("Paste text and press **Paraphrase text**."))
p_file = gr.File(label="Download as .docx", interactive=False)
p_dl = gr.HTML()
p_scores = gr.HTML(EMPTY_SCORES)
# ---------------- Admin (admins only) ----------------
with gr.Tab("🛡️ Admin", visible=False) as admin_tab:
ad_stats = gr.HTML()
with gr.Row():
ad_query = gr.Textbox(label="Search users", placeholder="Name or email", scale=4)
ad_refresh = gr.Button("🔄 Refresh", scale=1)
ad_export = gr.Button("⬇️ Export CSV", scale=1)
ad_table = gr.Dataframe(headers=ADMIN_COLS, interactive=False, wrap=True)
ad_csv = gr.File(label="Users export", interactive=False)
with gr.Row(equal_height=False):
with gr.Column(elem_classes="cs-card"):
gr.HTML('<div class="cs-settings-title">Enable / disable account</div>')
ad_email = gr.Textbox(label="User email")
with gr.Row():
ad_disable = gr.Button("🚫 Disable")
ad_enable = gr.Button("✅ Enable")
with gr.Column(elem_classes="cs-card"):
gr.HTML('<div class="cs-settings-title">Reset password</div>')
ad_pw_email = gr.Textbox(label="User email")
ad_new_pw = gr.Textbox(label="New password", type="password")
ad_reset = gr.Button("🔑 Reset password")
with gr.Column(elem_classes="cs-card"):
gr.HTML('<div class="cs-settings-title">Delete account</div>')
ad_del_email = gr.Textbox(label="User email")
ad_del_confirm = gr.Textbox(label="Type the email again to confirm")
ad_delete = gr.Button("🗑️ Delete user", variant="stop")
ad_msg = gr.HTML()
gr.HTML(FOOTER)
# ---------------- Events ----------------
auth_outputs = [user_state, auth_view, app_view, user_bar, auth_msg, admin_tab, ad_stats, ad_table]
ad_out = [ad_stats, ad_table, ad_msg]
hid = dict(show_progress="hidden", api_name=False)
ad_refresh.click(lambda u, q: _admin_refresh(u, q, "Refreshed."), [user_state, ad_query], ad_out, **hid)
ad_query.submit(admin_search, [user_state, ad_query], ad_table, **hid)
ad_disable.click(lambda u, e, q: admin_set_active(u, e, q, False), [user_state, ad_email, ad_query], ad_out, **hid)
ad_enable.click(lambda u, e, q: admin_set_active(u, e, q, True), [user_state, ad_email, ad_query], ad_out, **hid)
ad_reset.click(admin_reset_pw, [user_state, ad_pw_email, ad_new_pw, ad_query], ad_out, **hid)
ad_delete.click(admin_delete, [user_state, ad_del_email, ad_del_confirm, ad_query], ad_out, **hid)
ad_export.click(admin_export, [user_state], ad_csv, **hid)
li_btn.click(do_login, [li_email, li_pw], auth_outputs, show_progress="hidden", api_name=False)
li_pw.submit(do_login, [li_email, li_pw], auth_outputs, show_progress="hidden", api_name=False)
su_btn.click(do_signup, [su_name, su_email, su_pw, su_pw2], auth_outputs, show_progress="hidden", api_name=False)
logout_btn.click(do_logout, None, auth_outputs, show_progress="hidden", api_name=False)
d_btn.click(
process_docx,
inputs=[input_file, d_temp, d_min, d_mode, user_state],
outputs=[d_out, d_dl, d_status, d_scores, d_preview],
api_name=False,
show_progress="hidden",
)
p_in.change(count_words, inputs=p_in, outputs=p_count, show_progress="hidden", queue=False, api_name=False)
p_btn.click(
process_text,
inputs=[p_in, p_temp, p_min, p_mode, user_state],
outputs=[p_out, p_file, p_dl, p_status, p_scores],
api_name=False,
show_progress="hidden",
)
demo.queue(default_concurrency_limit=2, max_size=30)
if __name__ == "__main__":
demo.launch(
show_api=False,
allowed_paths=[str(OUT_DIR)],
favicon_path=str(FAVICON_PATH) if FAVICON_PATH.exists() else None,
)