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.

A stack of essays passing through a glowing scanner on a desk, with a printed report and a laptop beside it

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 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.

Table of Contents

  1. What You'll Build
  2. Before You Start
  3. Step 1: Load the Submissions
  4. Step 2: Wrap the Proofademic API
  5. Step 3: Scan the Whole Class Without Hitting Limits
  6. Step 4: Export the CSV and PDF Report
  7. Step 5: Run It
  8. What the Results Looked Like
  9. Using the Report Responsibly
  10. Ideas to Extend It
  11. Conclusion
  12. FAQs

1. What You'll Build

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

2. Before You Start

Get an API key. Sign in to the Proofademic Developer Portal, 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.

Creating an API key with AI Detector access in the Proofademic Developer Portal

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:

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

3. Step 1: Load the Submissions

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.

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.

4. Step 2: Wrap the Proofademic API

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 } 

5. Step 3: Scan the Whole Class Without Hitting Limits

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.

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:

  • The cache. Every finished result is written to 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.
  • The pending limit. 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 backoff. Polling starts at 2 seconds and doubles up to 30, which is the strategy the docs recommend. Failed jobs are refunded automatically, so the script just records the error and moves on.

6. Step 4: Export the CSV and PDF Report

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.

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"<span backColor='{sentence_color(item)}'>{escape(item['text'])}</span>") 

One rule in the summary table turned out to matter more than I expected. If a submission's overall verdict is human but some sentences were still flagged, the "AI sentences" cell turns red:

if ai and r["ai_score"] < 0.5: # passed overall, but some sentences were flagged: worth a look style.append(("TEXTCOLOR", (4, i), (4, i), colors.HexColor("#c62828"))) style.append(("FONTNAME", (4, i), (4, i), "Helvetica-Bold")) 

You'll see why in the results.

7. Step 5: Run It

Check how many credits a run will use before spending any:

$ python scan_class.py class_submissions --dry-run 8 submissions, 1196 words, about 1196 credits 

Then run the real scan:

python scan_class.py class_submissions \ --title "Period 3: What does education mean to you?" \ --max-pending 2 

The API's word count can run slightly above a plain split() count, so leave a little room. My eight submissions were estimated at 1,196 words and used 1,208 credits.

8. What the Results Looked Like

To test the script, I built a sample class of eight submissions to the prompt "What does education mean to you?":

  • Five ChatGPT essays. I asked ChatGPT for five responses in five different student voices. One of them is a .docx file to test the Word loader.
  • Two human essays. Since I can't share real student work, these are public-domain passages about learning to read, by Frederick Douglass (1845) and Booker T. Washington (1901).
  • One mixed essay. A public-domain passage by Helen Keller (1903), with a ChatGPT-written closing paragraph added at the end.

Results of one batch run through the Proofademic API

Every ChatGPT essay was flagged, both human essays passed, and the mixed essay was caught at the sentence level. Lower AI scores are better for human writing.

Student Who actually wrote it AI score AI sentences Verdict
Ava Brooks ChatGPT 94.4% 7 of 8 AI-generated
Lena Ortiz ChatGPT 75.1% 6 of 9 AI-generated
Owen Patel ChatGPT 73.8% 5 of 8 AI-generated
Marcus Reed ChatGPT (.docx) 73.4% 6 of 10 AI-generated
Sam Carter ChatGPT 62.8% 5 of 10 Likely AI
Priya Shah Human + ChatGPT ending 24.6% 2 of 12 Human
Noor Haddad Human 1.7% 0 of 4 Human
Daniel Kim Human 0.4% 0 of 8 Human

Here's the first page of the PDF the script produced:

Class summary page of the PDF report

Page 1 of class_report.pdf: the ranked class summary. Priya's sentence count is red because her "human" essay still has flagged sentences.

Every ChatGPT essay was caught. All five scored above 50%, with 5 to 7 sentences flagged in each. Even Sam Carter's essay, the most personal-sounding of the five, came back as likely AI.

Both human essays were cleared. Douglass scored 0.4% and Washington 1.7%, with zero flagged sentences between them.

The mixed essay is the reason sentence-level results matter. Priya Shah's overall score was 24.6%, which on its own reads as human. But the report shows that the two flagged sentences are both in the ChatGPT closing paragraph, while every sentence of the Keller passage is green:

Sentence highlights for the mixed submission

Priya Shah's page: the Keller passage is all green, and the two flagged sentences sit in the ChatGPT closing paragraph.

If you only looked at a single score per student, you'd have missed it. That's why the summary turns the sentence count red whenever a "human" submission still has flagged sentences.

Each AI-flagged essay gets a page like this one, so you can see exactly which lines triggered the score:

Student page with sentence-level highlights

Ava Brooks's page: 7 of 8 sentences flagged as AI-generated.

9. Using the Report Responsibly

A detector score is evidence, not a verdict. Before acting on any result:

  • Look at the sentences, not just the number. A single flagged sentence in a long essay is very different from a whole page in red.
  • Compare with the student's other work. A sudden change in voice is more telling than one score.
  • Talk to the student first. Ask them to explain their argument or walk you through their draft history.
  • Know your school's policy. Some schools allow AI for brainstorming or grammar checks, and others don't allow it at all.

The PDF says this at the top of every report, so whoever reads it later gets the same reminder.

Also keep student data in mind. Store the API key in an environment variable, keep the reports/ folder somewhere access-controlled, and delete the raw/ cache when the grading period ends.

10. Ideas to Extend It

  • Pull submissions from your LMS. Google Classroom, Canvas and Moodle all have APIs or bulk-download options that can fill the class_submissions folder.
  • Use webhooks for big classes. Instead of polling, pass a callback_url and a webhook_secret, and let Proofademic POST results to your server as each job finishes.
  • Add a plagiarism pass. The same API key can be given the Plagiarism Checker scope, which costs a flat 2 credits per scan.
  • Track a student over time. Keep each run's results.csv and compare scores across assignments to spot sudden changes.
  • Email each student's page. Split the PDF per student with pypdf and attach it to feedback.

11. Conclusion

With about 280 lines of Python, you can turn "paste 30 essays into a detector one at a time" into a single command that produces a ranked class summary and a sentence-level report for every student.

The design choices that matter most are the ones the API pushes you toward: use async mode with a pending-job limit instead of hammering the sync endpoint, cache every result so reruns are free, and put sentence-level results in front of the reader, not just one score. In my test, that last choice is what caught a ChatGPT paragraph hidden in an otherwise human essay.

Grab a Proofademic API key, point the script at a folder, and run --dry-run first to see what it will cost.

Full scan_class.py
""" scan_class.py: batch-scan a folder of student submissions with the Proofademic AI Detector API and export a PDF report plus a CSV. Usage: export PROOFADEMIC_API_KEY="prfd_..." python scan_class.py class_submissions --title "Period 3: What does education mean to you?" Requirements: pip install requests reportlab python-docx """ import argparse import csv import json import os import sys import time from datetime import date from pathlib import Path from xml.sax.saxutils import escape import requests from reportlab.lib import colors from reportlab.libSource:https://dev.to/mohab_karim/how-to-batch-scan-a-whole-class-for-ai-writing-and-export-pdf-reports-python-kfm

Related posts