""" AI Writing Report — Hugging Face Space Detector: coai/roberta-ai-detector-v2 (gated model -> add an HF_TOKEN secret whose account accepted the terms) Input: .docx / .pdf / .txt or pasted text -> highlighted PDF report + on-screen preview PDF and DOCX inputs keep their original layout: highlights are drawn onto the source pages (DOCX is converted to PDF with LibreOffice first), so figures, equations and tables are untouched. """ import os, re, html, datetime, tempfile, shutil, subprocess from dataclasses import dataclass, field import gradio as gr import docx_report as DX # ---------------------------------------------------------------- config MODEL_ID = os.getenv("DETECTOR_MODEL", "coai/roberta-ai-detector-v2") HF_TOKEN = os.getenv("HF_TOKEN") BRAND = os.getenv("REPORT_BRAND", "CS SOFTWARE SOLUTIONS") MIN_PARA_WORDS = 25 # shorter blocks (headings, captions) are not analysed WINDOW_WORDS = 90 # detector context per window (model is weak below ~50 words) MAX_WINDOW_WORDS = 320 # keeps each window under 512 tokens BATCH = 16 HIGH_COLOR = "#A9E4EE" # cyan MOD_COLOR = "#E4DCF5" # light purple # `spaces` is installed on every HF Space; only SPACES_ZERO_GPU=true means ZeroGPU hardware. ZERO = os.getenv("SPACES_ZERO_GPU", "").strip().lower() in ("1", "true", "yes") try: import spaces except Exception: spaces = None ZERO = ZERO and spaces is not None def _duration(texts): # ~0.05 s per window on ZeroGPU (+0.1 s with the second model) + load overhead; capped for long theses return min(300, 30 + int(len(texts) * (0.15 if MODEL2_ID else 0.05))) def gpu(fn): return spaces.GPU(duration=_duration)(fn) if ZERO else fn # ---------------------------------------------------------------- model # Loaded once at startup. On ZeroGPU the model must be created (and moved to # "cuda") at module level, not inside the @spaces.GPU function, or it is # re-downloaded / re-loaded on every request. _tok, _model = None, None # Second detector (ensemble). The first model was trained on older chatbot text and misses much of what current # models (GPT-4o/5, Claude, Gemini) write; this DeBERTa-v3-large detector was trained on the RAID benchmark (11 modern # generators, many domains, adversarial edits) and catches those. Final score = weighted mean of the two. # DETECTOR_MODEL2 default desklib/ai-text-detector-v1.01 ("" = single model, as before) # DETECTOR_WEIGHT2 default 0.7 weight of the second model # DETECTOR_COMBINE mean (default) | max (max flags text that EITHER model flags: more catches, more false alarms) MODEL2_ID = os.getenv("DETECTOR_MODEL2", "desklib/ai-text-detector-v1.01").strip() WEIGHT2 = float(os.getenv("DETECTOR_WEIGHT2", "0.7")) COMBINE = os.getenv("DETECTOR_COMBINE", "mean").strip().lower() _tok2, _model2, _m2_failed = None, None, False # Third detector: robust to paraphrasing (what Turnitin's "AI-paraphrased" check is about). Measured with # devtools/compare_detectors.py on 18 AI-written academic samples, 21 human abstracts from 2008 and 16 paragraphs our # own humanizer had brought to 6%: coai + desklib flagged 18/18 AI, 0/21 human, 1/16 humanized; this RoBERTa # (fakespot-ai) flagged 18/18, 2/21, 8/16. Averaged with the first two: 18/18 AI, 0/21 human, 6/16 humanized, so # the score is much harder to game without flagging human writing. # DETECTOR_MODEL3 default fakespot-ai/roberta-base-ai-text-detection-v1 ("" = off) # DETECTOR_WEIGHT3 default 0.5 share of the final score MODEL3_ID = os.getenv("DETECTOR_MODEL3", "fakespot-ai/roberta-base-ai-text-detection-v1").strip() WEIGHT3 = float(os.getenv("DETECTOR_WEIGHT3", "0.5")) _tok3, _model3, _m3_failed = None, None, False _cpu_m3 = None def load_model3(): global _tok3, _model3, _m3_failed if not MODEL3_ID or _m3_failed or _model3 is not None: return _tok3, _model3 try: import torch from transformers import AutoModelForSequenceClassification, AutoTokenizer _tok3 = AutoTokenizer.from_pretrained(MODEL3_ID, token=HF_TOKEN) _model3 = AutoModelForSequenceClassification.from_pretrained(MODEL3_ID, token=HF_TOKEN).eval() if ZERO or torch.cuda.is_available(): _model3.to("cuda") print(f"[detector] third model loaded: {MODEL3_ID}", flush=True) except Exception as err: _m3_failed = True print(f"[detector] third model not available ({err}); first two only", flush=True) return _tok3, _model3 # More detectors averaged with the score above, each counting as much as the whole ensemble before it. Chosen with # devtools/compare_detectors.py from 9 public detectors (18 AI-written academic samples, 21 human abstracts from # 2008, 16 paragraphs our own humanizer had rewritten): ensemble before 18/18 AI, 0/21 human, 6/16 humanized flagged; # ShantanuT01/vanguard (ModernBERT-large, MIT) 17/18, 1/21, 13/16; Oxidane/tmr (RoBERTa, MIT) 18/18, 1/21, 11/16; # the mean of the three: 18/18, 0/21, 11/16. Rejected: the most downloaded detector flagged every human text, and # three others flagged 7 to 18 of the 21. DETECTOR_EXTRA = comma-separated model ids ("" = none). EXTRA_IDS = [m.strip() for m in os.getenv( "DETECTOR_EXTRA", "ShantanuT01/vanguard-ai-text-detector,Oxidane/tmr-ai-text-detector").split(",") if m.strip()] _extras, _extras_failed, _extras_cpu = None, False, None def load_extras(cpu=False): """[(model id, tokenizer, model, AI label index or None for a single sigmoid output)] (GPU when there is one).""" global _extras, _extras_failed, _extras_cpu if cpu and _extras_cpu is not None: return _extras_cpu if not cpu and (_extras is not None or _extras_failed): return _extras or [] out = [] for mid in EXTRA_IDS: try: import torch from transformers import AutoModelForSequenceClassification, AutoTokenizer tok = AutoTokenizer.from_pretrained(mid, token=HF_TOKEN) m = AutoModelForSequenceClassification.from_pretrained(mid, token=HF_TOKEN).eval() if not cpu and (ZERO or torch.cuda.is_available()): m.to("cuda") labels = {int(k): str(v).lower() for k, v in m.config.id2label.items()} ai = None if m.config.num_labels == 1 else next( (i for i, l in labels.items() if l in ("ai", "fake", "machine", "generated", "label_1")), 1) out.append((mid, tok, m, ai)) print(f"[detector] extra model loaded: {mid}", flush=True) except Exception as err: print(f"[detector] extra model {mid} not available ({err}); left out", flush=True) if cpu: _extras_cpu = out else: _extras, _extras_failed = out, not out return out def _score_extra(tok, model, ai, texts): import torch dev = next(model.parameters()).device bs = 32 if dev.type == "cuda" else 8 res = [] for i in range(0, len(texts), bs): enc = tok(texts[i:i + bs], return_tensors="pt", truncation=True, max_length=512, padding=True).to(dev) enc.pop("token_type_ids", None) with torch.no_grad(), _amp(dev): lg = model(**enc).logits.float() p = torch.sigmoid(lg[:, 0]) if ai is None else torch.softmax(lg, -1)[:, ai] res.extend(p.cpu().tolist()) return res def _with_extras(base, texts, cpu=False): ps = [base] + [_score_extra(tok, m, ai, texts) for _, tok, m, ai in load_extras(cpu)] return [sum(x) / len(ps) for x in zip(*ps)] if len(ps) > 1 else base def _with3(p12, p3): if p3 is None: return p12 return [(1 - WEIGHT3) * a + WEIGHT3 * b for a, b in zip(p12, p3)] def _desklib_class(): import torch from torch import nn from transformers import AutoConfig, AutoModel, PreTrainedModel class DesklibAIDetectionModel(PreTrainedModel): config_class = AutoConfig def __init__(self, config): super().__init__(config) self.model = AutoModel.from_config(config) self.classifier = nn.Linear(config.hidden_size, 1) self.init_weights() def forward(self, input_ids, attention_mask=None): h = self.model(input_ids, attention_mask=attention_mask)[0] m = attention_mask.unsqueeze(-1).expand(h.size()).float() pooled = (h * m).sum(1) / m.sum(1).clamp(min=1e-9) return self.classifier(pooled) return DesklibAIDetectionModel def load_model2(): """Second detector, or None if disabled / not available (then the first model alone is used).""" global _tok2, _model2, _m2_failed if _model2 is None and MODEL2_ID and not _m2_failed: try: import torch from transformers import AutoTokenizer _tok2 = AutoTokenizer.from_pretrained(MODEL2_ID, token=HF_TOKEN) cls = _desklib_class() if "desklib" in MODEL2_ID.lower() else None if cls is not None: _model2 = cls.from_pretrained(MODEL2_ID, token=HF_TOKEN).eval() else: from transformers import AutoModelForSequenceClassification _model2 = AutoModelForSequenceClassification.from_pretrained(MODEL2_ID, token=HF_TOKEN).eval() if ZERO or torch.cuda.is_available(): _model2.to("cuda") print(f"[detector] second model loaded: {MODEL2_ID}", flush=True) except Exception as err: _m2_failed = True print(f"[detector] second model not available ({err}); first model only", flush=True) return _tok2, _model2 # Half precision on the GPU (about 2x faster on a T4). Used only after half_check() found that it gives the same # verdicts as full precision on the bundled samples; DETECTOR_HALF=0 = never. HALF = False HALF_WANTED = os.getenv("DETECTOR_HALF", "1") != "0" def _amp(dev): import contextlib import torch if HALF and dev.type == "cuda": return torch.autocast("cuda", dtype=torch.float16) return contextlib.nullcontext() def _score_with2(tok, model, texts): import torch dev = next(model.parameters()).device bs = 32 if dev.type == "cuda" else 8 out = [] for i in range(0, len(texts), bs): enc = tok(texts[i:i + bs], return_tensors="pt", truncation=True, max_length=768, padding=True).to(dev) with torch.no_grad(), _amp(dev): logits = model(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"]) logits = logits.logits if hasattr(logits, "logits") else logits if logits.shape[-1] == 1: p = torch.sigmoid(logits.float()).squeeze(-1) else: p = torch.softmax(logits.float(), dim=-1)[:, 1] out.extend(p.cpu().tolist()) return out def _combine(p1, p2): if p2 is None: return p1 if COMBINE == "max": return [max(a, b) for a, b in zip(p1, p2)] return [(1 - WEIGHT2) * a + WEIGHT2 * b for a, b in zip(p1, p2)] def load_model(): global _tok, _model if _model is None: import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification _tok = AutoTokenizer.from_pretrained(MODEL_ID, token=HF_TOKEN) _model = AutoModelForSequenceClassification.from_pretrained(MODEL_ID, token=HF_TOKEN).eval() if ZERO or torch.cuda.is_available(): _model.to("cuda") return _tok, _model if os.getenv("SKIP_MODEL_LOAD") != "1": load_model() load_model2() load_model3() load_extras() DX.start_lo_init() # fetch newest LibreOffice in the background def _score_with(tok, model, texts): import torch dev = next(model.parameters()).device bs = 64 if dev.type == "cuda" else BATCH out = [] for i in range(0, len(texts), bs): enc = tok(texts[i:i + bs], return_tensors="pt", truncation=True, max_length=512, padding=True).to(dev) with torch.no_grad(), _amp(dev): p = torch.softmax(model(**enc).logits.float(), dim=-1)[:, 1] out.extend(p.cpu().tolist()) return out @gpu def _score_gpu(texts): """P(AI) for each text. Label index 1 = AI-generated (per model card).""" tok, model = load_model() p1 = _score_with(tok, model, texts) tok2, model2 = load_model2() p2 = _score_with2(tok2, model2, texts) if model2 is not None else None tok3, model3 = load_model3() p3 = _score_with(tok3, model3, texts) if model3 is not None else None # label 1 = "ai" return _with_extras(_with3(_combine(p1, p2), p3), texts) _cpu_det, _cpu_det_lock = None, __import__("threading").Lock() LAST_ENGINE = "GPU" def _score_cpu(texts): global _cpu_det, _cpu_m3 import torch from transformers import AutoModelForSequenceClassification with _cpu_det_lock: if _cpu_det is None: _cpu_det = AutoModelForSequenceClassification.from_pretrained(MODEL_ID, token=HF_TOKEN).eval() import runtime as RT with RT.cpu_job(): p1 = _score_with(_tok or load_model()[0], _cpu_det, texts) tok2, model2 = load_model2() p2 = None if model2 is not None: if next(model2.parameters()).device.type != "cpu": model2 = _cpu_model2() p2 = _score_with2(tok2, model2, texts) p3 = None tok3, model3 = load_model3() if model3 is not None: if next(model3.parameters()).device.type != "cpu": with _cpu_det_lock: if _cpu_m3 is None: from transformers import AutoModelForSequenceClassification as _A _cpu_m3 = _A.from_pretrained(MODEL3_ID, token=HF_TOKEN).eval() model3 = _cpu_m3 p3 = _score_with(tok3, model3, texts) return _with_extras(_with3(_combine(p1, p2), p3), texts, cpu=True) _cpu_m2 = None def _cpu_model2(): """CPU copy of the second detector (ZeroGPU fallback).""" global _cpu_m2 with _cpu_det_lock: if _cpu_m2 is None: cls = _desklib_class() if "desklib" in MODEL2_ID.lower() else None if cls is not None: _cpu_m2 = cls.from_pretrained(MODEL2_ID, token=HF_TOKEN).eval() else: from transformers import AutoModelForSequenceClassification _cpu_m2 = AutoModelForSequenceClassification.from_pretrained(MODEL2_ID, token=HF_TOKEN).eval() return _cpu_m2 # Scores are deterministic per text, and the humanizer asks for the same texts again and again (candidates, the # chosen version, before/after of every polish step), so they are cached. New texts are scored shortest-first: # batches then carry little padding, which matters most on CPU. SCORE_CACHE_MAX = int(os.getenv("DETECTOR_CACHE", "20000")) _score_cache, _score_cache_lock = {}, __import__("threading").Lock() def half_check(): """On a GPU: score the bundled samples in full and in half precision; half precision is switched on only if no verdict (>= 0.5) changes and no score moves by more than 0.05. Logged either way.""" global HALF import time as _t try: import torch if not HALF_WANTED or ZERO or not torch.cuda.is_available(): return import sys as _sys from pathlib import Path as _P _sys.path.insert(0, str(_P(__file__).resolve().parent / "devtools")) from samples_domains import DOMAINS import samples texts = list(DOMAINS.values()) + [samples.HUMAN, samples.AI_TECH, samples.AI_SOCIAL] texts += [s for v in DOMAINS.values() for s in split_sentences(v)][:30] HALF = False t = _t.time(); a = _score_gpu(texts); ta = _t.time() - t HALF = True t = _t.time(); b = _score_gpu(texts); tb = _t.time() - t diff = max(abs(x - y) for x, y in zip(a, b)) flips = sum((x >= 0.5) != (y >= 0.5) for x, y in zip(a, b)) HALF = flips == 0 and diff <= 0.05 print(f"[detector] half precision {'ON' if HALF else 'OFF'}: {len(texts)} texts, full {ta:.1f}s, half {tb:.1f}s, " f"max score difference {diff:.3f}, verdicts changed {flips}", flush=True) except Exception as err: HALF = False print(f"[detector] half precision check failed, full precision kept: {err}", flush=True) def on_cpu(): """True when the detector runs on the CPU (no GPU): every model call is then expensive.""" try: return next(load_model()[1].parameters()).device.type == "cpu" and not ZERO except Exception: return True _light_cache = {} def score_light(texts): """P(AI) from the two small models only (coai + the paraphrase-robust RoBERTa), without the large DeBERTa: for ranking many candidate versions on the CPU, where the large model is about two thirds of the detector's time. The final verdict of a document always comes from score_texts (all three models).""" texts = list(texts) todo = [t for t in dict.fromkeys(texts) if t not in _light_cache] if todo: tok, model = load_model() p1 = _score_with(tok, model, todo) tok3, model3 = load_model3() p3 = _score_with(tok3, model3, todo) if model3 is not None else None p = p1 if p3 is None else [(a + b) / 2 for a, b in zip(p1, p3)] if len(_light_cache) > SCORE_CACHE_MAX: _light_cache.clear() _light_cache.update(zip(todo, p)) return [_light_cache[t] for t in texts] def score_texts(texts): """P(AI) per text (cached; only texts not seen before reach the model).""" texts = list(texts) with _score_cache_lock: known = {t: _score_cache[t] for t in texts if t in _score_cache} todo = sorted(set(texts) - known.keys(), key=len) if todo: fresh = dict(zip(todo, _score_uncached(todo))) known.update(fresh) with _score_cache_lock: if len(_score_cache) + len(fresh) > SCORE_CACHE_MAX: _score_cache.clear() _score_cache.update(fresh) return [known[t] for t in texts] def _score_uncached(texts): """GPU when available; falls back to CPU if the ZeroGPU quota / queue is exhausted.""" global LAST_ENGINE if not ZERO: on_gpu = next(load_model()[1].parameters()).device.type == "cuda" LAST_ENGINE = "GPU" if on_gpu else "CPU" if on_gpu: return _score_gpu(texts) import runtime as RT with RT.cpu_job(): # CPU-only hardware: share cores between users return _score_gpu(texts) try: LAST_ENGINE = "GPU" return _score_gpu(texts) except Exception as err: msg = str(err).lower() if not any(k in msg for k in ("quota", "zerogpu", "gpu task aborted", "cuda", "no gpu", "queue is full", "exceeded", "unlogged", "sign in")): raise print(f"[detector] GPU unavailable ({err}); scoring on CPU", flush=True) LAST_ENGINE = "CPU" return _score_cpu(texts) # ---------------------------------------------------------------- extraction @dataclass class Block: text: str kind: str = "para" # para | heading | table | skip sents: list = field(default_factory=list) rows: list = field(default_factory=list) # table cells (list of rows) scores: list = field(default_factory=list) words: list = field(default_factory=list) # raw words (layout path) toks: list = field(default_factory=list) # (page, block, line, bbox) per word spans: list = field(default_factory=list) # (start, end) word index per sentence el: object = None # w:p element (docx path) raw: str = "" # exact paragraph text (docx path) cspans: list = field(default_factory=list) # (start, end) char range per sentence REF_HEAD = re.compile(r"^\s*(\d+(\.\d+)*\.?\s*|[IVX]+\.\s*|chapter\s+\w+\s*[:.\-–]?\s*)?" r"(references?(\s+(list|cited))?|list\s+of\s+references|bibliography|works\s+cited|" r"literature\s+cited|sources|webliography)\s*[:.]?\s*$", re.I) # one reference-list entry (APA / Harvard / IEEE / Vancouver): never rewritten, wherever it appears REF_ENTRY = re.compile( r"^\s*(?:\[\d+\]|\d+\.)?\s*" # [12] / 12. r"(?:[A-Z][A-Za-z'’\-]+(?:\s[A-Z][A-Za-z'’\-]+)?,\s*(?:[A-Z]\.\s*-?)+" # Kumar, R. S. r"|[A-Z]\.\s*(?:[A-Z]\.\s*)?[A-Z][A-Za-z'’\-]+,)" # R. Kumar, r".{0,400}?(?:\(\s*\d{4}[a-z]?\s*\)|\b(?:19|20)\d{2}[a-z]?\b)") REF_HINT = re.compile(r"\bdoi\b|doi\.org|https?://|\bvol\.|\bpp\.|\bJournal\b|\bProceedings\b|" r"\bPress\b|\b\d+\s*\(\d+\)\s*,?\s*\d+", re.I) BULLET = re.compile(r"^\s*([•●▪\-\*–]|\(?[a-z0-9]{1,2}[\)\.])\s+") def _norm(t): return re.sub(r"[ \t ]+", " ", t.replace("\r", "")).strip() def extract_docx(path): import docx from docx.text.paragraph import Paragraph from docx.table import Table d = docx.Document(path) blocks = [] for child in d.element.body.iterchildren(): tag = child.tag.split("}")[-1] if tag == "p": p = Paragraph(child, d) t = _norm(p.text) if not t: continue style = (p.style.name or "").lower() if p.style is not None else "" kind = "heading" if ("heading" in style or "title" in style) else "para" if "list" in style and len(t.split()) < MIN_PARA_WORDS: kind = "skip" blocks.append(Block(t, kind)) elif tag == "tbl": tbl = Table(child, d) rows = [] for r in tbl.rows: cells, prev = [], None for c in r.cells: # merged cells repeat the same _tc if prev is not None and c._tc is prev: continue prev = c._tc cells.append(_norm(c.text)) rows.append(cells) rows = [r for r in rows if any(r)] if rows: blocks.append(Block("\n".join(" | ".join(r) for r in rows), "table", rows=rows)) return blocks def extract_pdf(path): import pymupdf as fitz blocks = [] with fitz.open(path) as doc: for page in doc: h = page.rect.height items = [] # (y, Block) tboxes = [] try: for tb in page.find_tables().tables: rows = [[_norm(c or "") for c in r] for r in tb.extract()] rows = [r for r in rows if any(r)] if len(rows) >= 2 and max(len(r) for r in rows) >= 2: tboxes.append(fitz.Rect(tb.bbox)) items.append((tb.bbox[1], Block("\n".join(" | ".join(r) for r in rows), "table", rows=rows))) except Exception: pass for b in page.get_text("blocks", sort=True): x0, y0, x1, y1, t, _, btype = b if btype != 0: continue if y1 < h * 0.06 or y0 > h * 0.94: # running header / footer continue r = fitz.Rect(x0, y0, x1, y1) if any(tr.intersects(r) and (tr & r).get_area() > 0.5 * r.get_area() for tr in tboxes): continue # text already captured in a table t = _norm(re.sub(r"-\n(?=[a-z])", "", t).replace("\n", " ")) if t: items.append((y0, Block(t))) blocks.extend(b for _, b in sorted(items, key=lambda x: x[0])) # re-join paragraphs split across page / column breaks merged = [] for b in blocks: if merged and merged[-1].kind == "para" and not re.search(r"[.!?:\"”)]$", merged[-1].text) \ and b.text[:1].islower(): merged[-1].text += " " + b.text else: merged.append(b) return merged def _dehyphen(t): return re.sub(r"(\w)- ([a-z])", r"\1\2", t) def extract_pdf_layout(path): """Paragraph blocks that remember where every word sits on the page.""" import pymupdf as fitz blocks = [] with fitz.open(path) as doc: for pno, page in enumerate(doc): h = page.rect.height tables, tboxes = [], [] try: for tb in page.find_tables().tables: rows = [[_norm(c or "") for c in r] for r in tb.extract()] rows = [r for r in rows if any(r)] if len(rows) >= 2 and max(len(r) for r in rows) >= 2: tboxes.append(fitz.Rect(tb.bbox)) tables.append((tb.bbox[1], Block("\n".join(" | ".join(r) for r in rows), "table", rows=rows))) except Exception: pass bywords = {} for w in page.get_text("words"): bywords.setdefault(w[5], []).append(w) page_blocks = [] for b in page.get_text("blocks", sort=True): x0, y0, x1, y1, _, bno, btype = b if btype != 0 or bno not in bywords: continue if y1 < h * 0.06 or y0 > h * 0.94: # running header / footer continue r = fitz.Rect(x0, y0, x1, y1) if any(tr.intersects(r) and (tr & r).get_area() > 0.5 * r.get_area() for tr in tboxes): continue # part of a table ws = sorted(bywords[bno], key=lambda w: (w[6], w[7])) words = [w[4] for w in ws] toks = [(pno, bno, w[6], (w[0], w[1], w[2], w[3])) for w in ws] page_blocks.append((y0, Block(" ".join(words), words=words, toks=toks))) # place detected tables at their vertical position for ty, tblk in sorted(tables, key=lambda x: x[0]): idx = next((i for i, (y, _) in enumerate(page_blocks) if y > ty), len(page_blocks)) page_blocks.insert(idx, (ty, tblk)) blocks.extend(b for _, b in page_blocks) # re-join paragraphs split across page / column breaks merged = [] for b in blocks: if merged and b.kind == "para" and merged[-1].kind == "para" and merged[-1].toks \ and not re.search(r"[.!?:\"”)]$", merged[-1].text) and b.text[:1].islower(): m = merged[-1] m.text += " " + b.text m.words += b.words m.toks += b.toks else: merged.append(b) return merged def layout_sentences(b): """Sentence spans over the block's word list (+ clean display text).""" sents = split_sentences(" ".join(b.words)) counts = [len(s.split()) for s in sents] if sum(counts) != len(b.words): sents, counts = [" ".join(b.words)], [len(b.words)] spans, i = [], 0 for c in counts: spans.append((i, i + c)) i += c b.spans = spans b.sents = [_dehyphen(s) for s in sents] def extract_docx_live(doc, table_prose=False): """Blocks bound to the paragraphs of an open python-docx Document. table_prose=True: paragraphs inside table cells become separate blocks (documents laid out in tables).""" blocks = [] for kind, obj in DX.iter_body(doc): if kind == "p": raw = DX.para_text(obj._p) t = re.sub(r"\s+", " ", raw).strip() if not t: continue style = (obj.style.name or "").lower() if obj.style is not None else "" k = "heading" if ("heading" in style or "title" in style) else "para" if ("list" in style or "caption" in style) and len(t.split()) < MIN_PARA_WORDS: k = "skip" blocks.append(Block(t, k, el=obj._p, raw=raw)) elif table_prose: seen = set() for cell_p in obj._tbl.iter(DX.qn("w:p")): if id(cell_p) in seen: continue seen.add(id(cell_p)) raw = DX.para_text(cell_p) t = re.sub(r"\s+", " ", raw).strip() if t: blocks.append(Block(t, "para", el=cell_p, raw=raw)) else: rows = [] for r in obj.rows: cells, prev = [], None for c in r.cells: if prev is not None and c._tc is prev: continue prev = c._tc cells.append(_norm(c.text)) rows.append(cells) rows = [r for r in rows if any(r)] if rows: blocks.append(Block("\n".join(" | ".join(r) for r in rows), "table", rows=rows)) return blocks PASTE_MIN_WORDS = 6 # pasted text: short prose paragraphs still count (windows span paragraphs) def docx_blocks(doc): """Blocks of a Word document, classified. Documents written in short paragraphs, bullet lists, heading-styled prose or table layouts would otherwise have "no qualifying prose", so the rules relax step by step until prose is found.""" blocks = classify(extract_docx_live(doc)) if any(b.kind == "para" for b in blocks): return blocks for min_words in (12, PASTE_MIN_WORDS): blocks = extract_docx_live(doc) for b in blocks: if b.kind == "heading" and len(b.text.split()) >= 15 and re.search(r"[.!?]$", b.text): b.kind = "para" # prose wrongly styled as Heading/Title elif b.kind == "skip" and b.el is not None: b.kind = "para" # bullet / list items written as full sentences blocks = classify(blocks, min_words) if any(b.kind == "para" for b in blocks): return blocks blocks = classify(extract_docx_live(doc, table_prose=True), PASTE_MIN_WORDS) # text laid out in tables return blocks def extract_text(raw): """Pasted / .txt text: single line breaks are paragraph breaks, PDF soft-wraps are re-joined.""" import textparse as TP return [Block(_norm(p)) for p, _ in TP.parse_paste(raw) if _norm(p)] def classify(blocks, min_words=MIN_PARA_WORDS): in_refs = False for b in blocks: if REF_HEAD.match(b.text): b.kind, in_refs = "heading", True continue if in_refs: b.kind = "skip" continue if b.kind in ("table", "skip", "heading"): continue if REF_ENTRY.match(b.text) and REF_HINT.search(b.text) and len(b.text.split()) < 120: b.kind = "skip" # a reference-list entry: never rewritten continue wc = len(b.text.split()) wordy = sum(1 for w in b.text.split() if re.fullmatch(r"[A-Za-z][A-Za-z'’\-]+[.,;:)]?", w)) if re.match(r"^\s*(\d+(\.\d+)*\.?|[IVX]+\.|[A-Z]\.)\s+[A-Z]", b.text) and wc <= 14 \ and not re.search(r"[.!?]$", b.text): b.kind = "heading" # "1. Introduction", "2.3 Results" continue if wc and wordy / wc < 0.55: # equations, symbols, numeric lists b.kind = "skip" continue if re.match(r"^(fig(ure)?|table)\s*\.?\s*\d", b.text, re.I) and wc < 40: b.kind = "skip" elif wc <= 14 and not re.search(r"[.!?:]$", b.text): b.kind = "heading" # titles / section headings elif BULLET.match(b.text) and wc < min_words: b.kind = "skip" elif wc < min_words: b.kind = "skip" return blocks # ---------------------------------------------------------------- sentences _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)" def split_sentences(t): import textparse as TP # one rule for what ends a sentence, in every tool t = TP.protect_dots(t) # (the closing quote / bracket after the full stop belongs to the sentence: it must not be consumed by the split) parts = re.split(r"(?:(?<=[.!?])|(?<=[.!?][\"”’)]))\s+(?=[A-Z0-9\[\(“\"])", t) return [p.replace("", ".").strip() for p in parts if p.strip()] # ---------------------------------------------------------------- analysis def make_windows(lengths): """Overlapping sentence windows of ~WINDOW_WORDS words (≈50% overlap).""" windows, i, n = [], 0, len(lengths) while i < n: j, wc = i + 1, lengths[i] while j < n and wc < WINDOW_WORDS and wc + lengths[j] <= MAX_WINDOW_WORDS: wc += lengths[j] j += 1 windows.append((i, j)) if j >= n: break i = max(i + 1, i + (j - i) // 2) return windows def analyse(blocks, scorer=None): scorer = scorer or score_texts # looked up at call time (tests may swap it) stream = [] # (block_idx, sent_idx, text, wordcount) for bi, b in enumerate(blocks): if b.kind != "para": continue if b.toks: layout_sentences(b) elif b.el is not None: raws = split_sentences(b.raw) b.cspans = DX.sentence_char_spans(b.raw, raws) b.sents = [re.sub(r"\s+", " ", x).strip() for x in raws] else: b.sents = split_sentences(b.text) b.scores = [0.0] * len(b.sents) for si, s in enumerate(b.sents): stream.append((bi, si, s, len(s.split()))) if not stream: return blocks windows = make_windows([x[3] for x in stream]) probs = scorer([" ".join(stream[k][2] for k in range(a, b)) for a, b in windows]) acc, cnt = [0.0] * len(stream), [0] * len(stream) for (a, b), p in zip(windows, probs): for k in range(a, b): acc[k] += p cnt[k] += 1 for k, (bi, si, _, _) in enumerate(stream): blocks[bi].scores[si] = acc[k] / max(cnt[k], 1) # light paragraph smoothing -> less speckled highlighting for b in blocks: if b.kind == "para" and b.scores: m = sum(b.scores) / len(b.scores) b.scores = [0.7 * s + 0.3 * m for s in b.scores] return blocks def summarise(blocks, hi, mod): total = hi_w = mod_w = 0 for b in blocks: if b.kind != "para": continue for s, p in zip(b.sents, b.scores): w = len(s.split()) total += w if p >= hi: hi_w += w elif p >= mod: mod_w += w pct = round(100 * (hi_w + mod_w) / total) if total else 0 return dict(pct=pct, analysed=total, high=hi_w, moderate=mod_w, all_words=sum(len(b.text.split()) for b in blocks)) def band(pct): if pct < 20: return "Low likelihood of AI-generated content" if pct <= 50: return "Moderate likelihood of AI-generated content" return "High likelihood of AI-generated content" # ---------------------------------------------------------------- PDF report def build_pdf(blocks, stats, fname, fsize_kb, hi, mod, out_path, summary_only=False): from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.lib import colors from reportlab.lib.styles import ParagraphStyle from reportlab.platypus import (BaseDocTemplate, Frame, PageTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak) from reportlab.pdfgen import canvas as rl_canvas from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont serif, serif_b, sans, sans_b = "Times-Roman", "Times-Bold", "Helvetica", "Helvetica-Bold" fdir = "/usr/share/fonts/truetype/dejavu/" try: for a, f in [("Serif", "DejaVuSerif.ttf"), ("SerifB", "DejaVuSerif-Bold.ttf"), ("Sans", "DejaVuSans.ttf"), ("SansB", "DejaVuSans-Bold.ttf")]: pdfmetrics.registerFont(TTFont(a, fdir + f)) serif, serif_b, sans, sans_b = "Serif", "SerifB", "Sans", "SansB" # Unicode-safe except Exception: pass now = datetime.datetime.now(datetime.timezone.utc).strftime("%B %d, %Y at %I:%M %p UTC") class NumberedCanvas(rl_canvas.Canvas): def __init__(self, *a, **k): super().__init__(*a, **k) self._saved = [] def showPage(self): self._saved.append(dict(self.__dict__)) self._startPage() def save(self): total = len(self._saved) for st in self._saved: self.__dict__.update(st) w, h = A4 self.setFont(sans, 8) self.setFillColor(colors.HexColor("#555555")) self.drawString(18 * mm, h - 12 * mm, BRAND) self.drawRightString(w - 18 * mm, h - 12 * mm, "AI WRITING REPORT") if not summary_only: self.drawCentredString(w / 2, 10 * mm, f"Page {self._pageNumber} of {total}") super().showPage() super().save() _n = [0] def S(**k): _n[0] += 1 return ParagraphStyle(name=f"s{_n[0]}", **k) body = S(fontName=serif, fontSize=12, leading=24, alignment=4, firstLineIndent=18) head = S(fontName=serif_b, fontSize=12, leading=22, spaceBefore=8, spaceAfter=2) plain = S(fontName=serif, fontSize=11, leading=16, textColor=colors.HexColor("#333333"), spaceAfter=6) tbls = S(fontName=serif, fontSize=9, leading=12, textColor=colors.HexColor("#555555"), spaceAfter=6) h1 = S(fontName=sans_b, fontSize=26, leading=32, spaceAfter=6) h2 = S(fontName=sans_b, fontSize=12, leading=16, spaceBefore=12, spaceAfter=4) small = S(fontName=sans, fontSize=9.5, leading=13.5, textColor=colors.HexColor("#333333")) esc = lambda t: html.escape(t, quote=False) cell = S(fontName=serif, fontSize=9.5, leading=12) cell_b = S(fontName=serif_b, fontSize=9.5, leading=12) def make_table(rows): ncol = max(len(r) for r in rows) rows = [r + [""] * (ncol - len(r)) for r in rows] # column widths proportional to content length, within page width lens = [max(min(len(r[c]), 60) for r in rows) + 4 for c in range(ncol)] W = 160 * mm widths = [W * l / sum(lens) for l in lens] data = [[Paragraph(esc(c), cell_b if i == 0 else cell) for c in r] for i, r in enumerate(rows)] t = Table(data, colWidths=widths, repeatRows=1, hAlign="CENTER") t.setStyle(TableStyle([ ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#9a9a9a")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#efefef")), ("VALIGN", (0, 0), (-1, -1), "TOP"), ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3), ("LEFTPADDING", (0, 0), (-1, -1), 4), ("RIGHTPADDING", (0, 0), (-1, -1), 4)])) t.spaceBefore, t.spaceAfter = 4, 10 return t story = [Spacer(1, 10), Paragraph(f"{stats['pct']}% detected as AI", h1), Paragraph("The percentage indicates the share of analysed text that the detector classified as " "likely AI-generated (high plus moderate likelihood).", small), Paragraph(f"{band(stats['pct'])}", S(fontName=sans, fontSize=10.5, leading=16, spaceBefore=6)), Paragraph("Important Notice", h2), Paragraph("This percentage is a probabilistic estimate from a machine-learning classifier and should be " "treated as an indicator, not a conclusion. Results vary with writing style, subject matter, " "non-native English and document structure, and false positives occur. Use this report as " "one reference point alongside human judgement.", small), Paragraph("Guidelines", h2), Paragraph("The report analyses long-form prose. Highlight colours show the likelihood of a passage being " "flagged: cyan for high, light purple for moderate, unhighlighted for low. Non-qualifying " "content (headings, short bullet points, tables, captions, references) is not analysed. " "Scanned or image-only pages cannot be read.", small), Paragraph("Details", h2)] t = Table([["File", esc(fname)[:60]], ["Word count", f"{stats['all_words']:,}"], ["Words analysed", f"{stats['analysed']:,}"], ["Document size", f"{fsize_kb:.1f} KB"], ["Detector", "CS AI Writing Suite detector"]], colWidths=[40 * mm, 110 * mm]) t.setStyle(TableStyle([("FONT", (0, 0), (-1, -1), sans, 9.5), ("FONT", (0, 0), (0, -1), sans_b, 9.5), ("BOTTOMPADDING", (0, 0), (-1, -1), 3)])) story += [t, Paragraph("Score Interpretation", h2)] t = Table([["0% – 19%", "Low likelihood of AI-generated content"], ["20% – 50%", "Moderate likelihood of AI-generated content"], ["51% – 100%", "High likelihood of AI-generated content"]], colWidths=[30 * mm, 120 * mm]) t.setStyle(TableStyle([("FONT", (0, 0), (-1, -1), sans, 9.5), ("BOTTOMPADDING", (0, 0), (-1, -1), 3)])) story += [t, Paragraph("Highlight Legend", h2)] t = Table([["", f"High likelihood of being flagged (≥ {hi:.0%})"], ["", f"Moderate likelihood of being flagged ({mod:.0%} – {hi:.0%})"], ["", f"Low likelihood of being flagged (< {mod:.0%})"]], colWidths=[10 * mm, 140 * mm], rowHeights=6 * mm) t.setStyle(TableStyle([("FONT", (0, 0), (-1, -1), sans, 9.5), ("BACKGROUND", (0, 0), (0, 0), colors.HexColor(HIGH_COLOR)), ("BACKGROUND", (0, 1), (0, 1), colors.HexColor(MOD_COLOR)), ("BOX", (0, 2), (0, 2), 0.5, colors.HexColor("#999999")), ("VALIGN", (0, 0), (-1, -1), "MIDDLE")])) story += [t, Spacer(1, 14), Paragraph(f"Report generated: {now}", small)] if summary_only: blocks = [] else: story.append(PageBreak()) for b in blocks: if b.kind == "para": parts = [] for s, p in zip(b.sents, b.scores): s = esc(s) if p >= hi: s = f'{s}' elif p >= mod: s = f'{s}' parts.append(s) story.append(Paragraph(" ".join(parts), body)) elif b.kind == "heading": story.append(Paragraph(esc(b.text), head)) elif b.kind == "table" and b.rows: story.append(make_table(b.rows)) elif b.kind == "table": story.append(Paragraph(esc(b.text).replace("\n", "
"), tbls)) else: story.append(Paragraph(esc(b.text), plain)) doc = BaseDocTemplate(out_path, pagesize=A4, leftMargin=25 * mm, rightMargin=25 * mm, topMargin=22 * mm, bottomMargin=20 * mm, title="AI Writing Report") doc.addPageTemplates([PageTemplate(id="p", frames=[ Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="f")])]) doc.build(story, canvasmaker=NumberedCanvas) return out_path def build_overlay_pdf(src_pdf, blocks, stats, fname, fsize_kb, hi, mod, out_path): """Summary page + the ORIGINAL pages with highlight annotations.""" import pymupdf as fitz rgb = lambda hx: tuple(int(hx[i:i + 2], 16) / 255 for i in (1, 3, 5)) hc, mc = rgb(HIGH_COLOR), rgb(MOD_COLOR) src = fitz.open(src_pdf) pages = {} # keep Page objects alive while annotating for b in blocks: if b.kind != "para" or not b.spans: continue for (a, e), p in zip(b.spans, b.scores): if p < mod: continue col = hc if p >= hi else mc lines = {} for pno, bno, lno, bbox in b.toks[a:e]: key = (pno, bno, lno) lines[key] = lines[key] | fitz.Rect(bbox) if key in lines else fitz.Rect(bbox) for (pno, _, _), r in lines.items(): pg = pages.get(pno) or pages.setdefault(pno, src[pno]) an = pg.add_highlight_annot(r) an.set_colors(stroke=col) an.update() summ = out_path + ".summary.pdf" build_pdf([], stats, fname, fsize_kb, hi, mod, summ, summary_only=True) final = fitz.open(summ) n_summary = len(final) final.insert_pdf(src, annots=True) n = len(final) grey = (0.35, 0.35, 0.35) for i, page in enumerate(final): if page.rotation: continue w, h = page.rect.width, page.rect.height label = f"Page {i + 1} of {n}" page.insert_text(((w - fitz.get_text_length(label, fontsize=7)) / 2, h - 8), label, fontsize=7, color=grey, overlay=True) if i >= n_summary: page.insert_text((20, 11), BRAND, fontsize=6.5, color=grey, overlay=True) rt = "AI WRITING REPORT" page.insert_text((w - 20 - fitz.get_text_length(rt, fontsize=6.5), 11), rt, fontsize=6.5, color=grey, overlay=True) final.save(out_path, garbage=3, deflate=True) final.close(); src.close() os.remove(summ) return out_path # ---------------------------------------------------------------- HTML preview def build_html(blocks, stats, hi, mod): col = "#c0392b" if stats["pct"] > 50 else "#d68910" if stats["pct"] >= 20 else "#1e8449" out = [f"""
{stats['pct']}% detected as AI
{band(stats['pct'])} · {stats['analysed']:,} of {stats['all_words']:,} words analysed
High Moderate Low
"""] for b in blocks: if b.kind == "para": segs = [] for s, p in zip(b.sents, b.scores): bg = HIGH_COLOR if p >= hi else MOD_COLOR if p >= mod else "transparent" segs.append(f'{html.escape(s)}') out.append("

" + " ".join(segs) + "

") elif b.kind == "heading": out.append(f"

{html.escape(b.text)}

") elif b.kind == "table" and b.rows: td = "border:1px solid #bbb;padding:3px 8px;text-align:left;vertical-align:top" trs = [] for i, r in enumerate(b.rows): tag = "th" if i == 0 else "td" bg = "background:#f0f0f0;" if i == 0 else "" trs.append("" + "".join(f"<{tag} style='{bg}{td}'>{html.escape(c)}" for c in r) + "") out.append("" + "".join(trs) + "
") else: out.append(f"

{html.escape(b.text).replace(chr(10), '
')}

") out.append("
") return "".join(out) # ---------------------------------------------------------------- pipeline def load_blocks(file, pasted): """Returns (blocks, fname, size_kb, source_pdf_or_None, docx_document_or_None).""" src_pdf, doc = None, None if file is not None: path = file if isinstance(file, str) else file.name ext = os.path.splitext(path)[1].lower() fname, size_kb = os.path.basename(path), os.path.getsize(path) / 1024 if ext == ".docx": try: doc = DX.open_clean(path) except Exception: raise gr.Error("Could not open this .docx (is it a valid Word file, not .doc?).") return docx_blocks(doc), fname, size_kb, None, doc elif ext == ".pdf": src_pdf = path blocks = extract_pdf_layout(path) elif ext in (".txt", ".md"): with open(path, encoding="utf-8", errors="ignore") as fh: blocks = extract_text(fh.read()) else: raise gr.Error("Upload a .docx, .pdf or .txt file.") elif pasted and pasted.strip(): blocks, fname, size_kb = extract_text(pasted), "Pasted text", len(pasted.encode()) / 1024 return classify(blocks, PASTE_MIN_WORDS), fname, size_kb, None, None else: raise gr.Error("Upload a file or paste text.") if file is not None and os.path.splitext(fname)[1].lower() in (".txt", ".md"): return classify(blocks, PASTE_MIN_WORDS), fname, size_kb, src_pdf, doc return classify(blocks), fname, size_kb, src_pdf, doc def run(file, pasted, hi, mod, progress=gr.Progress()): if mod >= hi: raise gr.Error("Moderate threshold must be below the high threshold.") progress(0.05, desc="Reading document") blocks, fname, size_kb, src_pdf, doc = load_blocks(file, pasted) if not any(b.kind == "para" for b in blocks): raise gr.Error("No qualifying prose found — paste full sentences (headings and fragments are " "not analysed). Scanned/image-only PDFs have no readable text.") progress(0.2, desc="Running detector") blocks = analyse(blocks) stats = summarise(blocks, hi, mod) base = re.sub(r"[^\w\-]+", "_", os.path.splitext(fname)[0])[:40] or "document" work = tempfile.mkdtemp() pdf = os.path.join(work, f"AI_Report_{base}.pdf") warn = "" if doc is not None: # ---- Word: inject, then render progress(0.7, desc="Highlighting the Word file") for b in blocks: if b.kind == "para" and b.el is not None and b.cspans: spans = [(sp[0], sp[1], (HIGH_COLOR if p >= hi else MOD_COLOR).lstrip("#")) for sp, p in zip(b.cspans, b.scores) if sp and p >= mod] DX.colour_paragraph(b.el, spans) docx_tmp = os.path.join(work, f"{base}_highlighted.docx") doc.save(docx_tmp) progress(0.8, desc="Converting to PDF") rendered = DX.convert_to_pdf(docx_tmp) if rendered: want, got = DX.count_docx_pictures(doc), DX.count_pdf_images(rendered) if got < want: warn = (f"{want - got} of {want} pictures from the Word file did not render in the PDF. " "For a report identical to Word, save the manuscript as PDF from Word " "and upload that PDF instead.") progress(0.9, desc="Building report") build_overlay_pdf(rendered, [], stats, fname, size_kb, hi, mod, pdf) else: warn = ("Word-to-PDF conversion failed, so this report is re-typeset text without " "figures/equations. Reason: " + (DX.LAST_ERROR or "unknown") + " — Workaround: save the manuscript as PDF from Word and upload the PDF.") build_pdf(blocks, stats, fname, size_kb, hi, mod, pdf) elif src_pdf: # ---- PDF: annotate original pages progress(0.85, desc="Building report") build_overlay_pdf(src_pdf, blocks, stats, fname, size_kb, hi, mod, pdf) else: # ---- plain text progress(0.85, desc="Building report") build_pdf(blocks, stats, fname, size_kb, hi, mod, pdf) if stats["analysed"] < 50: warn = ((warn + " ") if warn else "") + ( f"Only {stats['analysed']} words could be analysed. The detector is much less reliable " "below ~50 words — paste a longer passage for a trustworthy score.") meta = getattr(progress, "meta", None) # run log (dashboard) if isinstance(meta, dict): meta.update(name=fname, words=stats["all_words"], before=stats["pct"], output=pdf, engine=LAST_ENGINE) page = build_html(blocks, stats, hi, mod) if warn: page = (f"
⚠ {html.escape(warn)}
" + page) return page, pdf # (at the end of the module: half_check and everything it uses are defined by now) if os.getenv("SKIP_MODEL_LOAD") != "1": __import__("threading").Thread(target=half_check, daemon=True).start()