{"slug": "how-to-batch-scan-a-whole-class-for-ai-writing-and-export-pdf-reports-python", "title": "How to Batch-Scan a Whole Class for AI Writing and Export PDF Reports (Python)", "summary": "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.", "body_md": "*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.*\n\n*Image generated by AI*\n\nChecking 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.\n\nYou'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.\n\nI 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.\n\n```\nclass_submissions/            reports/\n├── 01_ava_brooks.txt         ├── class_report.pdf   (summary + one page per student)\n├── 02_daniel_kim.txt   ──▶   ├── results.csv        (one row per student)\n├── 04_marcus_reed.docx       └── raw/               (cached API responses)\n└── ...\n```\n\nThe script has four parts:\n\n| Part | What it does | \n|---|---|\n| Loader | Reads `.txt` ,`.md` and`.docx` files and turns`07_priya_shah.txt` into \"Priya Shah\" | \n| API client | Calls the Proofademic AI Detector with retries for `429` and`5xx` responses | \n| Batch scanner | Submits async jobs, keeps them under your plan's pending limit, polls with backoff, caches results | \n| Reporter | Writes a CSV and a PDF with a color-coded summary and sentence highlights | \n\n**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.\n\n*The Create API Key form: give the key a name, turn on AI Detector and pick an expiration.*\n\nNew 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.\n\n**Install the dependencies:**\n\n```\npip install requests reportlab python-docx\nexport PROOFADEMIC_API_KEY=\"prfd_your_key_here\"\n```\n\nA few API details shape the design of the script, all from the [Proofademic API docs](https://docs.proofademic.ai/):\n\n| Detail | Value | Why it matters | \n|---|---|---|\n| Endpoint | `POST /api/detector/` | One call per submission | \n| Auth | `X-API-Key` header | Keep the key in an env var, never in code | \n| Sync rate limit | 10 requests per minute | Too slow for a full class | \n| Async mode | `\"poll\": true` , then`GET /api/jobs/{task_id}/` | Not subject to the per-minute limit | \n| Pending async jobs | 2 on Trial, 5 on 300K, up to 50 on 25M | The batch loop must respect this | \n| Minimum length | 5 words | Shorter texts return `400` | \n| Result expiry | 1 hour | Save results as soon as they arrive | \n\nName 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.\n\n``` php\ndef read_submission(path: Path) -> str:\n    if path.suffix.lower() == \".docx\":\n        import docx  # python-docx\n        return \"\\n\\n\".join(p.text for p in docx.Document(path).paragraphs if p.text.strip())\n    return path.read_text(encoding=\"utf-8\", errors=\"ignore\")\n\ndef student_name(path: Path) -> str:\n    # \"07_priya_shah.txt\" -> \"Priya Shah\"\n    parts = [p for p in path.stem.split(\"_\") if not p.isdigit()]\n    return \" \".join(p.capitalize() for p in parts) or path.stem\n\ndef load_class(folder: Path) -> list[dict]:\n    subs = []\n    for path in sorted(folder.iterdir()):\n        if path.suffix.lower() not in {\".txt\", \".md\", \".docx\"}:\n            continue\n        text = read_submission(path).strip()\n        subs.append({\"file\": path.name, \"student\": student_name(path),\n                     \"text\": text, \"words\": len(text.split())})\n    return subs\n```\n\nParagraphs from Word files are joined with blank lines. That matters later, because the PDF uses those breaks to rebuild each student's paragraphs.\n\nThe 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.\n\n```\nBASE_URL = os.environ.get(\"PROOFADEMIC_BASE_URL\", \"https://developer-portal.proofademic.ai/api\")\n\nclass Proofademic:\n    def __init__(self, api_key: str):\n        self.s = requests.Session()\n        self.s.headers.update({\"X-API-Key\": api_key, \"Content-Type\": \"application/json\"})\n\n    def _request(self, method: str, path: str, **kw) -> requests.Response:\n        for attempt in range(6):\n            r = self.s.request(method, f\"{BASE_URL}{path}\", timeout=60, **kw)\n            if r.status_code == 429 or r.status_code >= 500:\n                wait = int(r.headers.get(\"Retry-After\", 2 ** attempt))\n                print(f\"  {r.status_code} from API, retrying in {wait}s\")\n                time.sleep(wait)\n                continue\n            return r\n        r.raise_for_status()\n        return r\n\n    def submit(self, text: str) -> dict:\n        r = self._request(\"POST\", \"/detector/\", json={\"content\": text, \"poll\": True})\n        if r.status_code not in (200, 202):\n            raise RuntimeError(f\"{r.status_code}: {r.text}\")\n        return r.json()\n\n    def job(self, task_id: str) -> dict:\n        r = self._request(\"GET\", f\"/jobs/{task_id}/\")\n        if r.status_code != 200:\n            raise RuntimeError(f\"{r.status_code}: {r.text}\")\n        return r.json()\n```\n\nErrors 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.\n\nHere's what a finished job looks like. The `items` array is the useful part for teachers, because it scores every sentence:\n\n```\n{\n  \"status\": \"success\",\n  \"result\": \"ai\",\n  \"ai_score\": 0.9436,\n  \"items\": [\n    {\n      \"text\": \"It is not just about getting good grades or passing exams.\",\n      \"prediction\": \"ai-generated\",\n      \"ai_score\": 0.9998\n    }\n  ],\n  \"word_count\": 143,\n  \"credits_remaining\": 1857\n}\n```\n\nThe 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.\n\nAsync 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.\n\n``` python\ndef scan_all(api: Proofademic, subs: list[dict], cache_dir: Path, max_pending: int) -> None:\n    cache_dir.mkdir(parents=True, exist_ok=True)\n    queue, pending = [], {}\n\n    for sub in subs:\n        cached = cache_dir / f\"{sub['file']}.json\"\n        if sub[\"words\"] < MIN_WORDS:\n            sub[\"error\"] = f\"too short ({sub['words']} words)\"\n        elif cached.exists():\n            sub[\"result\"] = json.loads(cached.read_text())\n        else:\n            queue.append(sub)\n\n    while queue or pending:\n        # keep the number of pending jobs under the plan limit\n        while queue and len(pending) < max_pending:\n            sub = queue.pop(0)\n            job = api.submit(sub[\"text\"])\n            if job[\"status\"] == \"success\":          # sync fallback\n                sub[\"result\"] = job\n                (cache_dir / f\"{sub['file']}.json\").write_text(json.dumps(job))\n            else:\n                pending[job[\"task_id\"]] = {\"sub\": sub, \"wait\": 2, \"next\": time.time() + 2}\n                print(f\"  submitted {sub['file']} ({sub['words']} words), \"\n                      f\"credits left: {job.get('credits_remaining')}\")\n\n        time.sleep(1)\n        for task_id, p in list(pending.items()):\n            if time.time() < p[\"next\"]:\n                continue\n            job = api.job(task_id)\n            if job[\"status\"] == \"processing\":\n                p[\"wait\"] = min(p[\"wait\"] * 2, 30)   # 2s, 4s, 8s ... capped at 30s\n                p[\"next\"] = time.time() + p[\"wait\"]\n                continue\n            sub = p[\"sub\"]\n            if job[\"status\"] == \"success\":\n                sub[\"result\"] = job\n                (cache_dir / f\"{sub['file']}.json\").write_text(json.dumps(job))\n                print(f\"  done {sub['file']}: {job['ai_score']:.0%} AI\")\n            else:\n                sub[\"error\"] = job.get(\"error\", \"failed\")\n                print(f\"  failed {sub['file']}: {sub['error']}\")\n            del pending[task_id]\n```\n\nThree details in this loop save you money and headaches:\n\n`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.\nThe 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.\n\n``` php\ndef band(score: float) -> tuple[str, str]:\n    # thresholds from the Proofademic docs\n    if score < 0.3:\n        return \"Human\", \"#2e7d32\"\n    if score < 0.5:\n        return \"Likely human\", \"#7cb342\"\n    if score < 0.7:\n        return \"Likely AI\", \"#f9a825\"\n    return \"AI-generated\", \"#c62828\"\n\ndef sentence_color(item: dict) -> str:\n    # red for sentences the API labels \"ai-generated\", green for \"original\"\n    return \"#ffcdd2\" if item[\"prediction\"] == \"ai-generated\" else \"#dcedc8\"\n```\n\nThe 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:\n\n```\nfor item in r.get(\"items\") or []:\n    start = s[\"text\"].find(item[\"text\"], pos)\n    if start > pos and \"\\n\" in s[\"text\"][pos:start] and spans:\n        paragraphs.append(spans)          # a line break means a new paragraph\n        spans = []\n    pos = max(pos, start + len(item[\"text\"]))\n    spans.append(f\"<span backColor='{sentence_color(item)}'>{escape(item['text'])}</span>\")\n```\n\nOne 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:\n\n```\nif ai and r[\"ai_score\"] < 0.5:\n    # passed overall, but some sentences were flagged: worth a look\n    style.append((\"TEXTCOLOR\", (4, i), (4, i), colors.HexColor(\"#c62828\")))\n    style.append((\"FONTNAME\", (4, i), (4, i), \"Helvetica-Bold\"))\n```\n\nYou'll see why in the results.\n\nCheck how many credits a run will use before spending any:\n\n``` bash\n$ python scan_class.py class_submissions --dry-run\n8 submissions, 1196 words, about 1196 credits\n```\n\nThen run the real scan:\n\n```\npython scan_class.py class_submissions \\\n  --title \"Period 3: What does education mean to you?\" \\\n  --max-pending 2\n```\n\nThe 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.\n\nTo test the script, I built a sample class of eight submissions to the prompt \"What does education mean to you?\":\n\n`.docx` file to test the Word loader.\n*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.*\n\n| Student | Who actually wrote it | AI score | AI sentences | Verdict | \n|---|---|---|---|---|\n| Ava Brooks | ChatGPT | 94.4% | 7 of 8 | AI-generated | \n| Lena Ortiz | ChatGPT | 75.1% | 6 of 9 | AI-generated | \n| Owen Patel | ChatGPT | 73.8% | 5 of 8 | AI-generated | \n| Marcus Reed | ChatGPT (.docx) | 73.4% | 6 of 10 | AI-generated | \n| Sam Carter | ChatGPT | 62.8% | 5 of 10 | Likely AI | \n| Priya Shah | Human + ChatGPT ending | 24.6% | 2 of 12 | Human | \n| Noor Haddad | Human | 1.7% | 0 of 4 | Human | \n| Daniel Kim | Human | 0.4% | 0 of 8 | Human | \n\nHere's the first page of the PDF the script produced:\n\n*Page 1 of class_report.pdf: the ranked class summary. Priya's sentence count is red because her \"human\" essay still has flagged sentences.*\n\n**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.\n\n**Both human essays were cleared.** Douglass scored 0.4% and Washington 1.7%, with zero flagged sentences between them.\n\n**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:\n\n*Priya Shah's page: the Keller passage is all green, and the two flagged sentences sit in the ChatGPT closing paragraph.*\n\nIf 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.\n\nEach AI-flagged essay gets a page like this one, so you can see exactly which lines triggered the score:\n\n*Ava Brooks's page: 7 of 8 sentences flagged as AI-generated.*\n\nA detector score is evidence, not a verdict. Before acting on any result:\n\nThe PDF says this at the top of every report, so whoever reads it later gets the same reminder.\n\nAlso 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.\n\n`class_submissions` folder.`callback_url` and a `webhook_secret`, and let Proofademic POST results to your server as each job finishes.`results.csv` and compare scores across assignments to spot sudden changes.`pypdf` and attach it to feedback.\nWith 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.\n\nThe 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.\n\nGrab a [Proofademic API key](https://platform.proofademic.ai/api-keys), point the script at a folder, and run `--dry-run` first to see what it will cost.\n\n```\n\"\"\"\nscan_class.py: batch-scan a folder of student submissions with the\nProofademic AI Detector API and export a PDF report plus a CSV.\n\nUsage:\n    export PROOFADEMIC_API_KEY=\"prfd_...\"\n    python scan_class.py class_submissions --title \"Period 3: What does education mean to you?\"\n\nRequirements:\n    pip install requests reportlab python-docx\n\"\"\"\n\nimport argparse\nimport csv\nimport json\nimport os\nimport sys\nimport time\nfrom datetime import date\nfrom pathlib import Path\nfrom xml.sax.saxutils import escape\n\nimport requests\nfrom reportlab.lib import colors\nfrom reportlab.lib.pagesizes import A4\nfrom reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet\nfrom reportlab.lib.units import mm\nfrom reportlab.platypus import (PageBreak, Paragraph, SimpleDocTemplate,\n                                Spacer, Table, TableStyle)\n\nBASE_URL = os.environ.get(\"PROOFADEMIC_BASE_URL\", \"https://developer-portal.proofademic.ai/api\")\nMIN_WORDS = 5  # the API rejects shorter texts with a 400\n\n# ---------------------------------------------------------------------------\n# 1. Reading submissions\n# ---------------------------------------------------------------------------\n\ndef read_submission(path: Path) -> str:\n    if path.suffix.lower() == \".docx\":\n        import docx  # python-docx\n        return \"\\n\\n\".join(p.text for p in docx.Document(path).paragraphs if p.text.strip())\n    return path.read_text(encoding=\"utf-8\", errors=\"ignore\")\n\ndef student_name(path: Path) -> str:\n    # \"07_priya_shah.txt\" -> \"Priya Shah\"\n    parts = [p for p in path.stem.split(\"_\") if not p.isdigit()]\n    return \" \".join(p.capitalize() for p in parts) or path.stem\n\ndef load_class(folder: Path) -> list[dict]:\n    subs = []\n    for path in sorted(folder.iterdir()):\n        if path.suffix.lower() not in {\".txt\", \".md\", \".docx\"}:\n            continue\n        text = read_submission(path).strip()\n        subs.append({\"file\": path.name, \"student\": student_name(path),\n                     \"text\": text, \"words\": len(text.split())})\n    return subs\n\n# ---------------------------------------------------------------------------\n# 2. Talking to the API\n# ---------------------------------------------------------------------------\n\nclass Proofademic:\n    def __init__(self, api_key: str):\n        self.s = requests.Session()\n        self.s.headers.update({\"X-API-Key\": api_key, \"Content-Type\": \"application/json\"})\n\n    def _request(self, method: str, path: str, **kw) -> requests.Response:\n        for attempt in range(6):\n            r = self.s.request(method, f\"{BASE_URL}{path}\", timeout=60, **kw)\n            if r.status_code == 429 or r.status_code >= 500:\n                wait = int(r.headers.get(\"Retry-After\", 2 ** attempt))\n                print(f\"  {r.status_code} from API, retrying in {wait}s\")\n                time.sleep(wait)\n                continue\n            return r\n        r.raise_for_status()\n        return r\n\n    def submit(self, text: str) -> dict:\n        r = self._request(\"POST\", \"/detector/\", json={\"content\": text, \"poll\": True})\n        if r.status_code not in (200, 202):\n            raise RuntimeError(f\"{r.status_code}: {r.text}\")\n        return r.json()\n\n    def job(self, task_id: str) -> dict:\n        r = self._request(\"GET\", f\"/jobs/{task_id}/\")\n        if r.status_code != 200:\n            raise RuntimeError(f\"{r.status_code}: {r.text}\")\n        return r.json()\n\ndef scan_all(api: Proofademic, subs: list[dict], cache_dir: Path, max_pending: int) -> None:\n    cache_dir.mkdir(parents=True, exist_ok=True)\n    queue, pending = [], {}\n\n    for sub in subs:\n        cached = cache_dir / f\"{sub['file']}.json\"\n        if sub[\"words\"] < MIN_WORDS:\n            sub[\"error\"] = f\"too short ({sub['words']} words)\"\n        elif cached.exists():\n            sub[\"result\"] = json.loads(cached.read_text())\n        else:\n            queue.append(sub)\n\n    while queue or pending:\n        # keep the number of pending jobs under the plan limit\n        while queue and len(pending) < max_pending:\n            sub = queue.pop(0)\n            job = api.submit(sub[\"text\"])\n            if job[\"status\"] == \"success\":          # sync fallback\n                sub[\"result\"] = job\n                (cache_dir / f\"{sub['file']}.json\").write_text(json.dumps(job))\n            else:\n                pending[job[\"task_id\"]] = {\"sub\": sub, \"wait\": 2, \"next\": time.time() + 2}\n                print(f\"  submitted {sub['file']} ({sub['words']} words), \"\n                      f\"credits left: {job.get('credits_remaining')}\")\n\n        time.sleep(1)\n        for task_id, p in list(pending.items()):\n            if time.time() < p[\"next\"]:\n                continue\n            job = api.job(task_id)\n            if job[\"status\"] == \"processing\":\n                p[\"wait\"] = min(p[\"wait\"] * 2, 30)   # 2s, 4s, 8s ... capped at 30s\n                p[\"next\"] = time.time() + p[\"wait\"]\n                continue\n            sub = p[\"sub\"]\n            if job[\"status\"] == \"success\":\n                sub[\"result\"] = job\n                (cache_dir / f\"{sub['file']}.json\").write_text(json.dumps(job))\n                print(f\"  done {sub['file']}: {job['ai_score']:.0%} AI\")\n            else:\n                sub[\"error\"] = job.get(\"error\", \"failed\")\n                print(f\"  failed {sub['file']}: {sub['error']}\")\n            del pending[task_id]\n\n# ---------------------------------------------------------------------------\n# 3. Reporting\n# ---------------------------------------------------------------------------\n\ndef band(score: float) -> tuple[str, str]:\n    # thresholds from the Proofademic docs\n    if score < 0.3:\n        return \"Human\", \"#2e7d32\"\n    if score < 0.5:\n        return \"Likely human\", \"#7cb342\"\n    if score < 0.7:\n        return \"Likely AI\", \"#f9a825\"\n    return \"AI-generated\", \"#c62828\"\n\ndef sentence_color(item: dict) -> str:\n    # red for sentences the API labels \"ai-generated\", green for \"original\"\n    return \"#ffcdd2\" if item[\"prediction\"] == \"ai-generated\" else \"#dcedc8\"\n\ndef write_csv(subs: list[dict], path: Path) -> None:\n    with path.open(\"w\", newline=\"\") as f:\n        w = csv.writer(f)\n        w.writerow([\"student\", \"file\", \"words\", \"ai_score\", \"verdict\", \"ai_sentences\", \"total_sentences\", \"error\"])\n        for s in subs:\n            r = s.get(\"result\")\n            if r:\n                items = r.get(\"items\") or []\n                ai = sum(1 for i in items if i[\"prediction\"] == \"ai-generated\")\n                w.writerow([s[\"student\"], s[\"file\"], s[\"words\"], round(r[\"ai_score\"], 4),\n                            band(r[\"ai_score\"])[0], ai, len(items), \"\"])\n            else:\n                w.writerow([s[\"student\"], s[\"file\"], s[\"words\"], \"\", \"\", \"\", \"\", s.get(\"error\", \"\")])\n\ndef write_pdf(subs: list[dict], path: Path, title: str) -> None:\n    styles = getSampleStyleSheet()\n    small = ParagraphStyle(\"small\", parent=styles[\"Normal\"], fontSize=8.5, textColor=colors.HexColor(\"#555555\"))\n    body = ParagraphStyle(\"body\", parent=styles[\"Normal\"], fontSize=10, leading=15)\n    story = [Paragraph(escape(title), styles[\"Title\"]),\n             Paragraph(f\"AI writing scan, {date.today():%B %d, %Y}. Scores from the Proofademic AI Detector API. \"\n                       \"Treat a high score as a reason to talk with the student, not as proof on its own.\", small),\n             Spacer(1, 6 * mm)]\n\n    rows = [[\"Student\", \"Words\", \"AI score\", \"Verdict\", \"AI sentences\"]]\n    style = [(\"BACKGROUND\", (0, 0), (-1, 0), colors.HexColor(\"#263238\")),\n             (\"TEXTCOLOR\", (0, 0), (-1, 0), colors.white),\n             (\"FONTNAME\", (0, 0), (-1, 0), \"Helvetica-Bold\"),\n             (\"GRID\", (0, 0), (-1, -1), 0.4, colors.HexColor(\"#cfd8dc\")),\n             (\"ROWBACKGROUNDS\", (0, 1), (-1, -1), [colors.white, colors.HexColor(\"#f5f7f8\")]),\n             (\"ALIGN\", (1, 1), (-1, -1), \"CENTER\")]\n    ranked = sorted(subs, key=lambda s: -(s.get(\"result\") or {}).get(\"ai_score\", -1))\n    for i, s in enumerate(ranked, start=1):\n        r = s.get(\"result\")\n        if not r:\n            rows.append([s[\"student\"], s[\"words\"], \"n/a\", s.get(\"error\", \"not scanned\"), \"\"])\n            continue\n        items = r.get(\"items\") or []\n        label, color = band(r[\"ai_score\"])\n        ai = sum(1 for x in items if x[\"prediction\"] == \"ai-generated\")\n        rows.append([s[\"student\"], s[\"words\"], f\"{r['ai_score']:.0%}\", label, f\"{ai} of {len(items)}\"])\n        style.append((\"TEXTCOLOR\", (3, i), (3, i), colors.HexColor(color)))\n        style.append((\"FONTNAME\", (3, i), (3, i), \"Helvetica-Bold\"))\n        if ai and r[\"ai_score\"] < 0.5:\n            # passed overall, but some sentences were flagged: worth a look\n            style.append((\"TEXTCOLOR\", (4, i), (4, i), colors.HexColor(\"#c62828\")))\n            style.append((\"FONTNAME\", (4, i), (4, i), \"Helvetica-Bold\"))\n    table = Table(rows, colWidths=[55 * mm, 18 * mm, 24 * mm, 38 * mm, 28 * mm])\n    table.setStyle(TableStyle(style))\n    story += [table, Spacer(1, 4 * mm),\n              Paragraph(\"Bands: under 30% Human, 30 to 50% Likely human, 50 to 70% Likely AI, \"\n                        \"70% and up AI-generated. A red sentence count on a human verdict means part \"\n                        \"of the text was flagged, so check that student's page.\", small)]\n\n    for s in ranked:\n        r = s.get(\"result\")\n        if not r:\n            continue\n        label, color = band(r[\"ai_score\"])\n        story += [PageBreak(),\n                  Paragraph(escape(s[\"student\"]), styles[\"Heading1\"]),\n                  Paragraph(f\"File: {escape(s['file'])} &nbsp;|&nbsp; {s['words']} words &nbsp;|&nbsp; \"\n                            f\"AI score: <b>{r['ai_score']:.0%}</b> &nbsp;|&nbsp; \"\n                            f\"<font color='{color}'><b>{label}</b></font>\", body),\n                  Spacer(1, 3 * mm),\n                  Paragraph(\"Highlighted sentences: red = flagged as AI-generated, green = original.\", small),\n                  Spacer(1, 3 * mm)]\n        # rebuild the student's paragraphs so the highlights read like the original\n        paragraphs, spans, pos = [], [], 0\n        for item in r.get(\"items\") or []:\n            start = s[\"text\"].find(item[\"text\"], pos)\n            if start > pos and \"\\n\" in s[\"text\"][pos:start] and spans:\n                paragraphs.append(spans)\n                spans = []\n            pos = max(pos, start + len(item[\"text\"]))\n            spans.append(f\"<span backColor='{sentence_color(item)}'>{escape(item['text'])}</span>\")\n        paragraphs.append(spans)\n        for spans in paragraphs:\n            story += [Paragraph(\" \".join(spans), body), Spacer(1, 3 * mm)]\n\n    SimpleDocTemplate(str(path), pagesize=A4, title=title,\n                      leftMargin=18 * mm, rightMargin=18 * mm,\n                      topMargin=16 * mm, bottomMargin=16 * mm).build(story)\n\n# ---------------------------------------------------------------------------\n# 4. CLI\n# ---------------------------------------------------------------------------\n\ndef main() -> None:\n    ap = argparse.ArgumentParser(description=\"Batch-scan a class for AI writing with Proofademic.\")\n    ap.add_argument(\"folder\", type=Path, help=\"folder of .txt, .md or .docx submissions\")\n    ap.add_argument(\"--out\", type=Path, default=Path(\"reports\"))\n    ap.add_argument(\"--title\", default=\"Class AI Writing Report\")\n    ap.add_argument(\"--max-pending\", type=int, default=2, help=\"async job limit for your plan (Trial = 2)\")\n    ap.add_argument(\"--dry-run\", action=\"store_true\", help=\"count words and credits without calling the API\")\n    args = ap.parse_args()\n\n    subs = load_class(args.folder)\n    total = sum(s[\"words\"] for s in subs)\n    print(f\"{len(subs)} submissions, {total} words, about {total} credits\")\n    if args.dry_run:\n        return\n\n    key = os.environ.get(\"PROOFADEMIC_API_KEY\")\n    if not key:\n        sys.exit(\"Set PROOFADEMIC_API_KEY first.\")\n\n    args.out.mkdir(parents=True, exist_ok=True)\n    scan_all(Proofademic(key), subs, args.out / \"raw\", args.max_pending)\n    write_csv(subs, args.out / \"results.csv\")\n    write_pdf(subs, args.out / \"class_report.pdf\", args.title)\n    print(f\"Saved {args.out / 'class_report.pdf'} and {args.out / 'results.csv'}\")\n\nif __name__ == \"__main__\":\n    main()\n```\n\n**How much does it cost to scan a class?**\n\nThe AI Detector costs 1 credit per word. A class of 30 essays at 500 words each is about 15,000 credits. The free trial includes 2,000 credits, and the smallest paid plan includes 300,000 credits a month for $49.\n\n**Why use async mode instead of the regular endpoint?**\n\nThe synchronous endpoint is limited to 10 requests per minute. Async requests aren't subject to that limit. They're only limited by your credits and by how many jobs can be pending at once (2 on the trial, 5 on the 300K plan, up to 50 on the largest plan).\n\n**Can it read Word documents and Google Docs?**\n\nIt reads `.txt`, `.md` and `.docx` files. Download Google Docs as `.docx` or plain text first, or extend `read_submission()` to handle other formats.\n\n**What happens if the script crashes halfway through?**\n\nEvery finished result is saved to `reports/raw/`. Run the script again and it skips anything already scanned, so you only pay for the submissions that didn't finish.\n\n**What's the shortest text it can scan?**\n\nThe API requires at least 5 words. The script skips shorter submissions and marks them in the report instead of sending them.\n\n**Should I use the AI score to grade students?**\n\nNo. Use it to decide which submissions to look at more closely, then read the flagged sentences and talk to the student. The report is a starting point for that conversation.", "url": "https://wpnews.pro/news/how-to-batch-scan-a-whole-class-for-ai-writing-and-export-pdf-reports-python", "canonical_source": "https://dev.to/mohab_karim/how-to-batch-scan-a-whole-class-for-ai-writing-and-export-pdf-reports-python-kfm", "published_at": "2026-11-14 20:37:12+00:00", "updated_at": "2026-11-14 20:51:04.527333+00:00", "lang": "en", "topics": ["ai-tools", "ai-products", "artificial-intelligence"], "entities": ["Proofademic", "ChatGPT", "Python", "reportlab", "python-docx"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-to-batch-scan-a-whole-class-for-ai-writing-and-export-pdf-reports-python", "markdown": "https://wpnews.pro/news/how-to-batch-scan-a-whole-class-for-ai-writing-and-export-pdf-reports-python.md", "text": "https://wpnews.pro/news/how-to-batch-scan-a-whole-class-for-ai-writing-and-export-pdf-reports-python.txt", "jsonld": "https://wpnews.pro/news/how-to-batch-scan-a-whole-class-for-ai-writing-and-export-pdf-reports-python.jsonld"}}