How to Batch-Scan a Whole Class for AI Writing and Export PDF Reports (Python) A developer published a 280-line Python script that batch-scans a folder of student submissions through the Proofademic AI Detector API, handling rate limits, async job polling and result caching, then exports a CSV and a PDF class report with sentence-level AI-original highlighting. In a sample run on eight submissions, the script flagged all five ChatGPT essays, cleared both human essays at under 2% AI, and surfaced a ChatGPT closing paragraph embedded in an otherwise human essay that the overall score alone would have missed. A 280-line Python script that sends a folder of student submissions to the Proofademic AI Detector API, handles rate limits and async jobs, and produces a class summary PDF with sentence-level highlights. Image generated by AI Checking one essay for AI writing is easy. Checking 30 of them the night before grades are due is where it gets painful: copy, paste, wait, screenshot, repeat. This tutorial automates the whole thing. You'll build a Python script that takes a folder of .txt and .docx submissions, scans every one with the Proofademic https://proofademic.ai/ AI Detector API, and writes two files: a CSV you can drop into a spreadsheet and a PDF report with a ranked class summary plus one page per student, where every sentence is highlighted as AI-generated or original. I ran it on a sample class of eight submissions. It caught all five ChatGPT essays, cleared both human essays at under 2% AI, and found a ChatGPT closing paragraph hidden inside an otherwise human essay that the overall score alone would have missed. class submissions/ reports/ ├── 01 ava brooks.txt ├── class report.pdf summary + one page per student ├── 02 daniel kim.txt ──▶ ├── results.csv one row per student ├── 04 marcus reed.docx └── raw/ cached API responses └── ... The script has four parts: | Part | What it does | |---|---| | Loader | Reads .txt , .md and .docx files and turns 07 priya shah.txt into "Priya Shah" | | API client | Calls the Proofademic AI Detector with retries for 429 and 5xx responses | | Batch scanner | Submits async jobs, keeps them under your plan's pending limit, polls with backoff, caches results | | Reporter | Writes a CSV and a PDF with a color-coded summary and sentence highlights | Get an API key. Sign in to the Proofademic Developer Portal https://platform.proofademic.ai/api-keys , open API Keys , and click Create API Key . Give it a name, switch on AI Detector under Model Access, and pick an expiration. The full key it starts with prfd is shown only once, so copy it straight into an environment variable. The Create API Key form: give the key a name, turn on AI Detector and pick an expiration. New accounts get a free trial with 2,000 credits for 5 days, no card required. The AI Detector costs 1 credit per word, so the trial covers about 2,000 words of student writing. That's enough to follow this tutorial with a small class. Paid plans start at 300,000 credits a month. Install the dependencies: pip install requests reportlab python-docx export PROOFADEMIC API KEY="prfd your key here" A few API details shape the design of the script, all from the Proofademic API docs https://docs.proofademic.ai/ : | Detail | Value | Why it matters | |---|---|---| | Endpoint | POST /api/detector/ | One call per submission | | Auth | X-API-Key header | Keep the key in an env var, never in code | | Sync rate limit | 10 requests per minute | Too slow for a full class | | Async mode | "poll": true , then GET /api/jobs/{task id}/ | Not subject to the per-minute limit | | Pending async jobs | 2 on Trial, 5 on 300K, up to 50 on 25M | The batch loop must respect this | | Minimum length | 5 words | Shorter texts return 400 | | Result expiry | 1 hour | Save results as soon as they arrive | Name each file with a number and the student's name, like 01 ava brooks.txt or 04 marcus reed.docx . The loader reads plain text and Word files and derives a readable name from the filename. php def read submission path: Path - str: if path.suffix.lower == ".docx": import docx python-docx return "\n\n".join p.text for p in docx.Document path .paragraphs if p.text.strip return path.read text encoding="utf-8", errors="ignore" def student name path: Path - str: "07 priya shah.txt" - "Priya Shah" parts = p for p in path.stem.split " " if not p.isdigit return " ".join p.capitalize for p in parts or path.stem def load class folder: Path - list dict : subs = for path in sorted folder.iterdir : if path.suffix.lower not in {".txt", ".md", ".docx"}: continue text = read submission path .strip subs.append {"file": path.name, "student": student name path , "text": text, "words": len text.split } return subs Paragraphs from Word files are joined with blank lines. That matters later, because the PDF uses those breaks to rebuild each student's paragraphs. The client is a thin wrapper around requests.Session . It sends the key on every request and retries 429 Too Many Requests and 5xx errors, using the Retry-After header when the API sends one. BASE URL = os.environ.get "PROOFADEMIC BASE URL", "https://developer-portal.proofademic.ai/api" class Proofademic: def init self, api key: str : self.s = requests.Session self.s.headers.update {"X-API-Key": api key, "Content-Type": "application/json"} def request self, method: str, path: str, kw - requests.Response: for attempt in range 6 : r = self.s.request method, f"{BASE URL}{path}", timeout=60, kw if r.status code == 429 or r.status code = 500: wait = int r.headers.get "Retry-After", 2 attempt print f" {r.status code} from API, retrying in {wait}s" time.sleep wait continue return r r.raise for status return r def submit self, text: str - dict: r = self. request "POST", "/detector/", json={"content": text, "poll": True} if r.status code not in 200, 202 : raise RuntimeError f"{r.status code}: {r.text}" return r.json def job self, task id: str - dict: r = self. request "GET", f"/jobs/{task id}/" if r.status code = 200: raise RuntimeError f"{r.status code}: {r.text}" return r.json Errors like 400 , 401 and 403 aren't retried on purpose. They mean something you need to fix: a text under 5 words, a bad key, a key without the AI Detector scope, or not enough credits. Here's what a finished job looks like. The items array is the useful part for teachers, because it scores every sentence: { "status": "success", "result": "ai", "ai score": 0.9436, "items": { "text": "It is not just about getting good grades or passing exams.", "prediction": "ai-generated", "ai score": 0.9998 } , "word count": 143, "credits remaining": 1857 } The synchronous endpoint allows 10 requests a minute. That's fine for one essay, but it would take three minutes just to send a class of 30, and any mistake costs you a rate-limit wait. Async mode avoids that. You submit with "poll": true , get a task id back immediately with HTTP 202 , then poll /api/jobs/{task id}/ until the job is done. The only limit is how many jobs can be pending at once, which depends on your plan. python def scan all api: Proofademic, subs: list dict , cache dir: Path, max pending: int - None: cache dir.mkdir parents=True, exist ok=True queue, pending = , {} for sub in subs: cached = cache dir / f"{sub 'file' }.json" if sub "words" < MIN WORDS: sub "error" = f"too short {sub 'words' } words " elif cached.exists : sub "result" = json.loads cached.read text else: queue.append sub while queue or pending: keep the number of pending jobs under the plan limit while queue and len pending < max pending: sub = queue.pop 0 job = api.submit sub "text" if job "status" == "success": sync fallback sub "result" = job cache dir / f"{sub 'file' }.json" .write text json.dumps job else: pending job "task id" = {"sub": sub, "wait": 2, "next": time.time + 2} print f" submitted {sub 'file' } {sub 'words' } words , " f"credits left: {job.get 'credits remaining' }" time.sleep 1 for task id, p in list pending.items : if time.time < p "next" : continue job = api.job task id if job "status" == "processing": p "wait" = min p "wait" 2, 30 2s, 4s, 8s ... capped at 30s p "next" = time.time + p "wait" continue sub = p "sub" if job "status" == "success": sub "result" = job cache dir / f"{sub 'file' }.json" .write text json.dumps job print f" done {sub 'file' }: {job 'ai score' :.0%} AI" else: sub "error" = job.get "error", "failed" print f" failed {sub 'file' }: {sub 'error' }" del pending task id Three details in this loop save you money and headaches: reports/raw/ . If the script crashes halfway or you rerun it to tweak the PDF, finished submissions aren't scanned again, so you aren't charged twice. max pending defaults to 2, the Trial limit. On a paid plan, raise it with --max-pending 5 or higher and the class finishes faster. The score bands come straight from the Proofademic docs: under 0.3 is human, 0.3 to 0.5 likely human, 0.5 to 0.7 likely AI, and 0.7 and up AI-generated. php def band score: float - tuple str, str : thresholds from the Proofademic docs if score < 0.3: return "Human", " 2e7d32" if score < 0.5: return "Likely human", " 7cb342" if score < 0.7: return "Likely AI", " f9a825" return "AI-generated", " c62828" def sentence color item: dict - str: red for sentences the API labels "ai-generated", green for "original" return " ffcdd2" if item "prediction" == "ai-generated" else " dcedc8" The PDF uses ReportLab's platypus layer. The first page is a summary table ranked by AI score. Then each student gets a page with their text, rebuilt into the original paragraphs, where each sentence gets a red or green background: for item in r.get "items" or : start = s "text" .find item "text" , pos if start pos and "\n" in s "text" pos:start and spans: paragraphs.append spans a line break means a new paragraph spans = pos = max pos, start + len item "text" spans.append f"