Spaces:
Running
Running
unknown
Claude Opus 5.5
Stronger AI detector: two more paraphrase-robust models averaged into the score
dc37bdf Download detector.py from Rasheed/Test_AI_writing: direct link, hf CLI and curl.
- Browser
- Download file 53.2 kB
-
https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/detector.py
- Command line
-
hf download hf://spaces/Rasheed/Test_AI_writing/detector.py
-
curl -L -o detector.py https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/detector.py
53.2 kB
| """ | |
| 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 | |
| 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 | |
| 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("<DOT>", ".").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"<b>{band(stats['pct'])}</b>", 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'<font backColor="{HIGH_COLOR}">{s}</font>' | |
| elif p >= mod: | |
| s = f'<font backColor="{MOD_COLOR}">{s}</font>' | |
| 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", "<br/>"), 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"""<div style="font-family:system-ui;padding:14px 18px;border:1px solid #ddd;border-radius:10px;margin-bottom:12px"> | |
| <div style="font-size:34px;font-weight:700;color:{col}">{stats['pct']}% detected as AI</div> | |
| <div style="color:#555">{band(stats['pct'])} · {stats['analysed']:,} of {stats['all_words']:,} words analysed</div> | |
| <div style="margin-top:6px;font-size:13px;color:#111"><span style="background:{HIGH_COLOR};padding:1px 8px">High</span> | |
| <span style="background:{MOD_COLOR};padding:1px 8px">Moderate</span> | |
| <span style="padding:1px 8px;border:1px solid #ccc">Low</span></div></div> | |
| <div style="font-family:Georgia,serif;line-height:1.9;max-height:640px;overflow:auto;padding:4px 12px;color:#111;background:#fff;border-radius:8px">"""] | |
| 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'<span title="P(AI) = {p:.2f}" style="background:{bg}">{html.escape(s)}</span>') | |
| out.append("<p style='text-align:justify'>" + " ".join(segs) + "</p>") | |
| elif b.kind == "heading": | |
| out.append(f"<p><b>{html.escape(b.text)}</b></p>") | |
| 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("<tr>" + "".join(f"<{tag} style='{bg}{td}'>{html.escape(c)}</{tag}>" for c in r) + "</tr>") | |
| out.append("<table style='border-collapse:collapse;margin:6px auto 14px;font-size:0.88em'>" | |
| + "".join(trs) + "</table>") | |
| else: | |
| out.append(f"<p style='color:#777;font-size:0.9em'>{html.escape(b.text).replace(chr(10), '<br>')}</p>") | |
| out.append("</div>") | |
| 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"<div style='font-family:system-ui;background:#fff4e5;border:1px solid #f0b458;" | |
| f"color:#6b4400;padding:10px 14px;border-radius:8px;margin-bottom:10px'>⚠ {html.escape(warn)}</div>" | |
| + 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() | |