Skip to main content
 █████╗ ██╗      ██████╗ ███████╗████████╗███████╗ ██████╗████████╗
██╔══██╗██║      ██╔══██╗██╔════╝╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝
███████║██║█████╗██║  ██║█████╗     ██║   █████╗  ██║        ██║
██╔══██║██║╚════╝██║  ██║██╔══╝     ██║   ██╔══╝  ██║        ██║
██║  ██║██║      ██████╔╝███████╗   ██║   ███████╗╚██████╗   ██║
╚═╝  ╚═╝╚═╝      ╚═════╝ ╚══════╝   ╚═╝   ╚══════╝ ╚═════╝   ╚═╝

SCORE YOUR OWN PROSE // BEFORE A TEACHER SCORES IT FOR YOU

a local, offline AI-writing detector and IB word counter for drafts you actually wrote

pypi language runs model license telemetry


🔍 What is this

A command-line tool that reads a .docx or .txt and scores each paragraph 0–1 on how AI-generated it reads, using the desklib/ai-text-detector-v1.01 DeBERTa model — the one sitting at #1 on the RAID benchmark. Everything runs on your own machine; after the first model download it never touches the network. You point it at your Extended Essay, it tells you which paragraphs sound like a language model wrote them.

The point isn't to cheat a detector. It's the opposite: I write my own drafts, and sometimes my own honest prose still trips these classifiers because that's what earnest formal writing looks like to them. This flags those paragraphs so I can reword before a teacher runs Turnitin and has an awkward conversation with me about it.

It is directional, not oracular. A high score means "reword this," not "you're caught." It is not, and cannot be, the number Turnitin shows a teacher.

nick@aidetect:~$ aidetect score EE-clean.txt
P  1   0.08  [##------------------]
P  2   0.71  [##############------]  <-- AI-ish
average AI score: 0.34   |   1/6 paragraphs flagged
reminder: directional only, not a Turnitin score.

🧠 The detection engine

feature what it actually does
01 per-paragraph scoring what it actually catches — splits your draft and scores each paragraph, so you fix the two bad ones instead of rewriting everything
02 docx + txt input reads Word files through the same walker count uses — no cover page, no contents, no headings, no bibliography — or plain text split on blank lines
03 prose extractor aidetect extract dumps a draft's countable prose to a .txt so you can eyeball exactly what got counted
04 offline after setup first run pulls ~1.5GB of model, every run after is airgapped — your essay never leaves the laptop
05 optional third opinion Ejhfast/fast-ai-detector, a separate lighter tool, for when both built-in detectors agree and you still want another read. Not part of any verdict
06 Binoculars (Gemma 4) a training-free perplexity-ratio detector — near chance with small Qwen pairs, but 92% on the labelled set once swapped to a Gemma 4 pair; see below
07 IB word count aidetect count (--json for scripts and agents) counts what the IB counts — no cover page, contents, headings, captions, tables, footnotes, citations or bibliography — and splits the total by section and sub-section, so an over-long draft tells you where
08 Turnitin-shaped segments score --segments slides overlapping 7-sentence windows across the prose — short connective paragraphs included, the ones paragraph mode skips — and reports % of prose in flagged segments, the same shape as Turnitin's headline number
09 combined verdict aidetect check runs the desklib segments and Binoculars over the same draft and takes the worst opinion per sentence — disagreement between detectors surfaces instead of averaging away

🚀 Run it

uv tool install aidetect      # or: pipx install aidetect
aidetect                                   # list the seven subcommands
aidetect count "draft.docx" --limit 4000   # IB word count, by section
aidetect count "draft.docx" --json         # same, as one JSON object
aidetect extract "draft.docx"              # -> "draft prose.txt", what got counted
aidetect score "draft.docx"                # score a whole draft, paragraph by paragraph
aidetect score "draft.docx" --segments     # Turnitin-shaped: % of prose in flagged windows
aidetect score --text "one sentence"       # score a single string
aidetect bino  "draft.docx" --mlx --pair gemma
aidetect check "draft.docx"                # both detectors, worst opinion wins

count and extract are instant and need no model. score and bino need a machine that can hold a transformer: built and tested on an 18GB Apple Silicon Mac, MPS-accelerated. Their first run downloads the model and will sit there for a minute — that's normal, not a hang. Every run after is fast and offline.

On Apple Silicon the Gemma 4 MLX pair installs automatically. Elsewhere it is skipped and the Qwen pairs still work.

--json

count takes --json and prints exactly one object on stdout, nothing else:

{"sections": [{"title": "Introduction", "words": 812, "level": 1},
              {"title": "Analysis", "words": 0, "level": 1},
              {"title": "Porter's Five Forces", "words": 1021, "level": 2}],
 "total": 3940, "limit": 4000, "over": -60}

Every key is always present. limit and over are null when no --limit was given — that means "does not apply", not "could not be read". A draft with no prose is an empty sections list and exit 0. Errors go to stderr with a non-zero exit, so consumers branch on the exit code rather than parsing error text.

words is a section's own words, never its children's, so sum(words) equals total and nothing double-counts. The table you see rolls sub-sections up into their parent for display; do the same yourself with level if you want subtotals.

What counts

Excluded by position, not by wording: everything before the first heading (the cover page), the Table of Contents section, headings themselves, Figure 3: ... captions, tables, footnotes, and everything from the Bibliography heading on. In-text citations are stripped from the paragraphs that survive.

Block quotes and body bullet lists do count — they are assessed prose. A caption needs its colon to be dropped, so Figure 4 shows revenue rising is counted while Figure 4: Revenue, 2021–2025 is not. A draft with no headings at all is counted whole, with a note, rather than reporting a confident zero.

🔩 Under the hood

flowchart LR
    A[".docx"] --> B["walk()<br/>structure: drop cover, contents,<br/>headings, captions, bibliography"]
    B --> C["aidetect count<br/>strip citations,<br/>total by section"]
    B --> P["is_prose()<br/>style floor: >= 25 words,<br/>no bullets or scaffolding"]
    B --> S["sentence windows<br/>7 sentences, stride 3<br/>no length floor"]
    P --> D1["desklib DeBERTa<br/>per paragraph"]
    P --> BN["Binoculars<br/>Gemma 4 pair<br/>per paragraph"]
    S --> D2["desklib DeBERTa<br/>per window"]
    D1 --> O1["aidetect score<br/>0-1 + flags"]
    D2 --> O2["score --segments<br/>% of prose flagged"]
    BN --> O3["aidetect bino<br/>vs calibrated threshold"]
    O2 --> K["aidetect check<br/>worst opinion<br/>per sentence"]
    O3 --> K

One walker decides what is in the document; everything downstream is a different question asked of the same paragraphs. A 6-word sentence is a word the examiner counts and noise to a paragraph scorer, which is why count and is_prose are separate filters rather than one.

The two scoring paths differ on purpose. Paragraph mode applies the 25-word floor, because a fragment scores as noise on its own. Segment mode drops it and slides overlapping windows instead, so those same short connective sentences get scored inside a window with their neighbours — and formulaic linking prose is exactly what detectors flag. check runs the segment path and Binoculars together and takes the worse verdict per sentence.

file job
src/aidetect/cli.py the aidetect entry point — dispatches subcommands, importing each lazily so count never loads torch
src/aidetect/text.py shared, torch-free: walk() reads a .docx's structure once, is_prose() is the detector's separate style filter
src/aidetect/count.py the IB word count — sections, rollup, citation stripping, budget
src/aidetect/detect.py loads the desklib model, scores each paragraph, prints the bars and flags
src/aidetect/segments.py torch-free window arithmetic for --segments and check: sentence split, overlapping windows, worst-window scores, red/amber/clean
src/aidetect/check.py runs both detectors over one draft and unions the verdicts — worst opinion wins, no ensemble weights
src/aidetect/extract.py dumps a .docx's countable prose into a .txt, the same words count counts
src/aidetect/binoculars.py training-free perplexity-ratio scorer over a base+instruct LM pair (Qwen, or Gemma 4 via --mlx; see below)
src/aidetect/calibrate.py fits a threshold on a labelled set you supply, saves it to ~/.config/aidetect
src/aidetect/generate.py generates the AI half of a calibration set through NVIDIA NIM, with no system prompt and no style guidance, so the adversary stays fair
src/aidetect/paths.py where thresholds are looked up — ~/.config/aidetect first, then the ones in the package
src/aidetect/thresholds/ the thresholds shipped with the package; a threshold you fit yourself wins over these
corpora/ labelled calibration sets: human/ai (humanities) and human-tech/ai-tech (maths, CS, science), plus peer for genre context. Repo-only, deliberately not shipped in the package
tests/ count rules, Binoculars math and segment windows, all self-checking, no model download
pyproject.toml package metadata and dependencies — torch · transformers · python-docx, plus mlx-vlm on Apple Silicon

🔭 Binoculars: shelved, then revived by Gemma 4

Binoculars is a training-free detector: run text through two LMs that share a tokenizer (a base "observer" and an instruct "performer") and divide perplexity by cross-perplexity. Its designed model pair is Falcon-7B ×2 (~28GB) — too big for an 18GB Mac, so the fallback was a small same-family pair (Qwen2.5-0.5B or 1.5B) that fits.

aidetect calibrate scores a labelled set — mine is 12 real pre-2020 IB Extended Essay paragraphs vs 12 LLM-written ones on the same topics — and finds the best separating threshold. Measured across pairs:

pair best separation chance
Qwen2.5-0.5B 62% 50%
Qwen2.5-1.5B 67% 50%
Gemma 4 E2B 92% 50%

The Qwen pairs sit near a coin flip: their human and AI score clusters almost completely overlap, because the perplexity gap Binoculars exploits is sharp in larger models and mush in sub-2B ones. That was the original negative result. Swapping in a Gemma 4 pair opens a clean gap (human mean 0.90 vs AI 0.69) and separates the set at 92%. Gemma 4 ships as a multimodal checkpoint, so --mlx quantizes it to 4-bit and runs it text-only through mlx-vlm, fitting the 18GB Mac in ~6GB:

aidetect bino IA-clean.txt --mlx --pair gemma   # uses the shipped threshold

# refit the threshold on your own labelled set
aidetect calibrate --human-dir corpora/human --ai-dir corpora/ai --mlx --pair gemma

The AI class has to be a fair adversary

aidetect generate builds the AI half of a calibration set by calling a hosted model through NVIDIA NIM with no system prompt and no style guidance — only a topic and a word count:

export NVIDIA_API_KEY=nvapi-...     # you set it; aidetect only reads the env var
aidetect generate --topics corpora/human-tech/manifest.json \
                  --out-dir corpora/ai-tech --prefix ta --seed 7

By default it reads NIM's live catalog and samples popular models across vendors, one per topic — twelve topics typically means twelve different models from twelve vendors, so the class carries a spread of tokenizers, architectures and training mixes rather than one model's habits. Models that NIM has stopped serving are dropped rather than 404-ing mid-run, --seed makes an assignment reproducible, and --model pins exact ids instead. The Gemma family is never sampled: it is the detector's own pair, and scoring Gemma output with a Gemma observer/performer would flatter the detector.

This exists because of a real failure. An earlier AI set was written by an assistant carrying house style rules (write tightly, no em dashes, active voice) and came out stylometrically indistinguishable from the human class it was supposed to oppose — mean sentence length 25.8 against the humans' 24.5, where a properly untuned AI class ran to 35.1. A too-human AI class pulls the clusters together, lowers the threshold and makes the detector more lenient, which is the false-negative direction this tool exists to avoid. That set is quarantined in corpora/ai-tech/ with the numbers written down.

Repeat --model to rotate models across topics so the class is not one model's quirks, and prefer a family different from the detector's own pair. The output manifest records the model, temperature and exact prompt template for every sample, so any threshold fitted on it can be audited.

Genre thresholds: --tag

The boundary is not only per model pair, it is per genre. Fitted on 12 real pre-2013 technical Extended Essay paragraphs (maths, CS, physics, chemistry, ITGS), human technical prose means 0.828 where humanities prose means 0.901. The humanities threshold of 0.826 therefore sits almost exactly at the mean of genuine human technical writing, so a maths or CS essay flags roughly half its paragraphs no matter who wrote it.

--tag keeps a second calibration beside the first instead of overwriting it:

aidetect calibrate --human-dir corpora/human-tech --ai-dir corpora/ai-tech \
                   --mlx --pair gemma --tag tech      # -> threshold-gemma-mlx-tech.json
aidetect check "IA Graph Theory.docx" --tag tech      # judged against technical prose
calibration threshold human mean AI mean separation
default (humanities EEs) 0.826 0.901 0.692 92%
--tag tech (maths/CS/science) 0.753 0.828 0.650 92%

Measured on four of my own drafts, moving to the technical threshold cut the flag rate on the maths IA from 28% to 13% and the CS IA from 51% to 32%, while the two humanities drafts (3% and 13%) did not move at all.

Both separate 92%, so the genre effect is a shift in level, not a loss of discrimination: technical prose scores lower for everyone, and once the threshold moves with it the detector works just as well. An earlier run measured 79% here and was wrongly read as "Binoculars is weak on technical prose" — that number came from a contaminated AI class, and the story is in corpora/ai-tech/README.md.

desklib stays the primary detector; Binoculars is now a usable second opinion rather than a dead end. The calibration sets are not shipped with the package — clone the repo to reproduce the numbers, or point --human-dir/--ai-dir at your own. Your fitted threshold lands in ~/.config/aidetect and takes precedence over the shipped one, so it survives an upgrade.

🧪 The peer set: genre context, not a human class

The 12 human samples are Extended Essays: English, History, Biology, Physics, Philosophy. A CS IA is a different animal, all database schemas, GUI components and method-by-method justification, and technical prose is inherently more predictable token-by-token, which drags perplexity-ratio scores down no matter who typed it. So a CS IA scoring below the EE human mean means less than it looks.

corpora/peer/ holds 12 paragraphs of real IB Computer Science IA prose (5 projects, 5 authors: sudokuMaster, IBOrganizer, MyCalendar, and two IBO-published new-syllabus specimens). Measured against the same anchors:

set Binoculars mean desklib mean
human (2008 EEs, verified pre-2020) 0.90 n/a
human-tech (2008–2013 maths/CS/science EEs, verified) 0.83 n/a
peer (CS IAs, 2021–2025) 0.85 0.46
ai (LLM-written, matched topics) 0.69 n/a

The genre gap is real and it is about 0.07 on Binoculars, measured against human-tech — twelve technical EE paragraphs that are provably pre-AI, which is the anchor peer could only approximate. peer sitting at 0.85, between the two verified human classes, is consistent with that gap rather than evidence of anything about the students who wrote it.

Score a technical draft with --tag tech, which encodes exactly this shift.

It is not a human class and it never fits a threshold. Every source postdates ChatGPT; three of the five were written in 2025. None carries an authorship attestation, and in 2025 a fair share of student IAs were not written unaided. Fold that into human/ and any AI-assisted sample drags the mean down, lowers the threshold, and the tool starts clearing drafts for the wrong reason, a detector that reassures instead of measures. aidetect calibrate reads only the two folders you name, so peer/ stays out of threshold fitting by construction, not by discipline.

What it can tell you: "my prose scores like other IAs in this genre." What it can never tell you: "my prose is human." Matching a set you cannot vouch for proves you are not an outlier, nothing more.

Stack: python · pytorch · transformers · mlx-vlm · desklib DeBERTa

The cross-check that matters is built in: aidetect check runs both detectors and takes the worse verdict. Ejhfast/fast-ai-detector is a separate, lighter tool in its own repo, not vendored and not part of check's verdict — reach for it only when you want a read from a model neither detector shares.


Nick Trimandylis

I WRITE MY OWN ESSAYS — THIS JUST CHECKS THEY STILL READ LIKE IT

MIT licensed — see LICENSE.

Metadata

Release files for aidetect 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for aidetect 0.3.0
File Size Uploaded
aidetect-0.3.0.tar.gz 47.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for aidetect 0.3.0
File Interpreter ABI Platform
aidetect-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 90.5 kB

Release files / aidetect-0.3.0.tar.gz

Download URL aidetect-0.3.0.tar.gz
Size 47.7 kB
Tags Source
SHA-256 checksum
How to use checksums
2b2ad058748e1b09adb2c1db8d73093fe8b785132d1ddea2293fe82e96e5e766
BLAKE2b-256 checksum
How to use checksums
dc03c64916f840031438d03f7133100064cdad2ffe2bca46983742b0eb7ede23
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 27, 2026.

Transparency log

Release files / aidetect-0.3.0-py3-none-any.whl

Download URL aidetect-0.3.0-py3-none-any.whl
Size 42.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
22bef07234433dbb26b0efc030424ac6af0774da3b6ccf28e9eb15752083b5e6
BLAKE2b-256 checksum
How to use checksums
1a105486d15d02a92ccbea60c34a11fd258c236c7fbce7dfe40723f6c8bd5c70
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 27, 2026.

Transparency log

Release history Release notifications | RSS feed

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.0

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page