Paper Checker
Building
Product · AI

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

Built with
Next.jsNext.js
TypeScriptTypeScript
ReactReact
PrismaPrisma
SQLiteSQLite
Gemini APIGemini API
DockerDocker
Project Details
RESOURCES

STATUS
Building
YEAR

2026

TYPE

Product · AI

TAGS
LLM
Education
Document Processing

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_ADMIN creates schools and platform users, SCHOOL_ADMIN creates teachers and students inside one school, TEACHER builds checking sets and runs evaluations, STUDENT reads 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 PENDING before the model is called; if the call or the parse fails, it flips to FAILED with 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:hash and compared with timingSafeEqual behind a length guard.
  • HS256 sessions signed with jose, seven-day expiry, in an httpOnly paper_checker_session cookie whose Secure flag 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/data and /app/uploads, schema initialised on first boot, and demo seeding behind a SEED_DEMO_DATA flag.

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
RESOURCES

STATUS
Building
YEAR

2026

TYPE

Product · AI

TAGS
LLM
Education
Document Processing