More Projects
59 in totalPaper Checker
2026
Role-based school app for grading handwritten exam papers with Gemini. Teachers upload a question paper PDF, an optional answer key and scanned answer sheets; the evaluation comes back question-wise and is stored so teachers, school admins and students can revisit it. Four roles (super admin, school admin, teacher, student) sit behind cookie-based JWT auth, with Prisma over SQLite and a Docker/Compose deployment.
Project Details
2026
Product · AI
Grading handwritten answer sheets is the part of a teacher's job that scales worst. A model that can read a scan and reason against a question paper changes the arithmetic, but only if the result is auditable and lands somewhere a school can actually use. Paper Checker is that wrapper: four roles, a stored report per submission, and every prompt kept on record.
The part people underestimate is the auditing. A grader that returns a number and forgets what it was asked is worse than no grader at all, because the first disputed mark ends the trust and there is nothing to inspect. So the design constraint here is not accuracy, it is provenance: for any mark on any sheet, you should be able to pull up the exact prompt text sent, the exact bytes of the question paper and key it was sent with, and the raw response that came back. Everything else in the schema follows from wanting that row to exist.
What shipped
- Four roles as a Prisma enum -
SUPER_ADMINcreates schools and platform users,SCHOOL_ADMINcreates teachers and students inside one school,TEACHERbuilds checking sets and runs evaluations,STUDENTreads their own reports. - Checking sets carrying title, subject, class grade, section, total marks, a question paper PDF, an optional answer key PDF, and free-text custom instructions that get appended to the grading prompt.
- Submission upload with student name and roll number, written to
uploads/schools/<schoolId>/submissions/<setId>/under a sanitized, timestamped filename. - Question-wise reports stored one-to-one against the submission: a per-question breakdown of max marks, awarded marks, feedback and deduction reason, alongside a summary, strengths and improvements, the prompt used and the raw response.
- Failure is a state, not an exception. The submission row is written
PENDINGbefore the model is called; if the call or the parse fails, it flips toFAILEDwith the reason attached and the uploaded sheet is still there to retry against. - scrypt password hashing with a 16-byte random salt, a 64-byte derived key, stored as
salt:hashand compared withtimingSafeEqualbehind a length guard. - HS256 sessions signed with
jose, seven-day expiry, in an httpOnlypaper_checker_sessioncookie whoseSecureflag is gated on an env var - a Secure cookie is never returned over plain HTTP, which would silently break login on an http:// deployment. - zod schemas shared between the react-hook-form clients and the API route handlers.
- Six API routes (
auth/login,auth/logout,schools,users,checking-sets,submissions) each opening with a role check before touching the database. - Docker and Compose with named volumes for
/app/dataand/app/uploads, schema initialised on first boot, and demo seeding behind aSEED_DEMO_DATAflag.
Five models and the relations that matter
School is the tenant boundary, with a unique code. User hangs off it with a nullable schoolId
that sets null on delete, so removing a school orphans its users rather than deleting them, and
carries the student-shaped optional fields - class grade, section, roll number - on the same table
rather than in a subtype.
The relations that do the real work are on Submission, which points at three things: the
CheckingSet it belongs to, an optional student user, and a required reviewerTeacherId. Splitting
the student from the reviewer is what lets a teacher upload a sheet for a student who has no login
yet - the name and roll number are stored on the submission itself, and linking to a User record is
optional. User therefore holds two distinct relations back to submissions, one as author and one as
reviewer, which is why both are named explicitly in the schema.
EvaluationReport is one-to-one with the submission behind a unique key, and stores the question
breakdown as serialised JSON rather than as child rows. That is a real fork: child rows would let you
query "every question 4 across the class" in SQL, while a JSON column keeps the report atomic with
the response it came from and avoids a second write that could half-fail. For a first version whose
job is auditability rather than analytics, keeping the report indivisible was the right side of it.
CheckingSet also carries a defaultPromptVersion, which is the hook for changing the house prompt
later without invalidating what older reports were graded under.
The Gemini call
All three PDFs go up base64-inlined as inline_data parts in a single generateContent
request alongside the prompt - question paper, optional key, answer sheet, in that order.
temperature is 0.2 and responseMimeType is application/json, with a regex that pulls
the first {...} block out of the text as a fallback for when the model wraps its output
anyway. The default prompt tells the examiner to award step marks for partial reasoning,
ignore struck-out answers, grade to the expected level for the class, and say so plainly if
the scan is unreadable. The model id itself comes from an env var so it can be moved without a
rebuild, and the response shape is spelled out in the prompt as a literal JSON skeleton, which is
what makes the parse worth attempting at all.
Every report stores the exact prompt used and the raw API response. If a mark is disputed later, you can see what the model was asked and what it actually said.
Authorisation, and where it is enforced
There are two enforcement styles and they are deliberately different. Server components call
requireSession and requireRole, which redirect - to /login if there is no session, to
/dashboard if the role is wrong - because a person who lands on the wrong page should be moved, not
shown an error. API routes call getAuthorizedApiSession, which returns a 401 or 403 as JSON,
because a fetch needs a status code.
Underneath that, the scoping is per-handler and per-tenant. A school admin creating a user is
rejected with 403 if the target schoolId is not their own, and the create-user zod schema only
accepts SCHOOL_ADMIN, TEACHER or STUDENT, so no API path can mint a second super admin. A
teacher submitting an answer sheet looks the checking set up with findFirst on the id and their
own user id, so a valid id belonging to another teacher returns a 404 rather than grading against
someone else's paper. Uploaded filenames are stripped to alphanumerics, dots and hyphens and prefixed
with a timestamp before they touch the disk.
How it deploys
The image is a three-stage build on node:22-bookworm-slim: a deps stage running npm ci, a builder
that generates the Prisma client and produces Next's standalone output, and a slim runner that copies
only .next/standalone, the static assets, the Prisma directory and the scripts. The runner installs
sqlite3 and openssl, declares volumes for /app/data and /app/uploads, and hands off to an
entrypoint that creates the database from scripts/init-db.sql only when the file does not already
exist, then optionally seeds demo data before exec-ing the server. Compose maps host 9000 to the
container's 8081 and keeps both volumes named, so the database and every uploaded PDF survive a
rebuild.
Where it stands
Building. The web app runs end to end and the container deploys, but prisma db push was
unreliable in the dev environment, so schema creation currently goes through a checked-in
scripts/init-db.sql. That works, and it is the reason the container boots cleanly on a fresh
volume, but it means there are now two descriptions of the schema that have to be kept in step by
hand. SQLite was chosen to keep startup cheap; the datasource swap to Postgres is the next real piece
of work, and it retires the SQL file at the same time.
The other unfinished edge is that evaluation runs inline inside the upload request. A teacher
uploading a sheet holds the connection open for the whole model round trip, and a browser timeout
loses the response even though the row and the file are safely on disk. FAILED is a recoverable
state by design, but there is no retry endpoint pointed at it yet - which, with the original file,
the stored prompt and the raw response all already persisted, is a small piece of work sitting on top
of groundwork that was laid for exactly this.
Project Details
2026
Product · AI