verbalE
A practice platform for the IELTS exam: scanned textbooks become scored tests, including a live spoken exam with an AI examiner.
- NestJS
- Next.js
- FastAPI
- Postgres
What makes IELTS hard to practice
IELTS is the English exam people take for university or a visa. It has four papers. Reading and Listening are multiple choice and gap-fill, so they can be scored with ordinary rules. Writing is an essay, and Speaking is a conversation of about 14 minutes with an examiner. Those two need a model to grade them, and Speaking needs someone to talk to.
The practice material people trust is the published Cambridge books. They exist as paper, or as a scan of paper. There is no feed of ready-made questions. Turning a scanned book of roughly 150 pages into clickable questions, with an answer key, is most of the work.
What a learner does
They sign in, pick a paper, and start an attempt. A timer runs, and answers save themselves as they work. Reading and Listening are scored as soon as they submit. Writing takes longer, so the result page waits until the grade arrives. Speaking is a separate flow: a microphone check, then a live voice room with an AI examiner that runs the three parts of the real exam and scores the recording afterwards.
Why it is four products, not one
One website could not hold this. Reading a PDF needs Python libraries for page images and layout, and a spoken exam is a live audio session that lasts for minutes. That is a poor fit for a normal web request, which is expected to finish quickly.
So there are four deployable pieces. A NestJS backend owns accounts, the database, and the business rules. A Python service does the slow and expensive work: reading PDFs, grading essays, scoring speaking, and generating tests. Two Next.js sites serve learners, and teachers and admins. The websites talk only to the backend. They never touch the database. The same API can serve a mobile app later without a second backend.
How the pieces talk
The Python service is not public. Only the backend can call it, and every call carries a shared secret. Those jobs run for minutes, past any normal request timeout, so the backend does not wait. It starts the job, gets an immediate acknowledgement, and the Python service sends the result back when it is done.
The shape of each request is defined once and shared. The backend checks it at runtime, and both websites get their types from the same definition. The Python service uses the matching models. Speaking audio is the exception: it goes straight from the browser to the voice provider. A conversational reply needs to land in about a second and a half, and an extra hop through our own API does not fit.
What I built
I built the whole product.
On the backend, that is sign-in and role checks, the scoring that turns raw marks into an IELTS band, and the life of an attempt from start to autosave to submit to result. The band conversion has unit tests, because that number has to be right. I also built the book-import jobs, the speaking endpoints that issue a room token and enforce a monthly minutes limit per plan, and a retry queue for side effects that gives up after five attempts. Shared plumbing covers a request id that follows a call across both services and into the logs, one error format, and a rate limit backed by Redis.
On the Python service, I built both ways of reading a book, the page classifiers, the chart cropper, the grading for reading and writing, the live examiner, and the router that picks which model a given feature uses for a given paid plan.
On the websites, I built the test runner, the autosave and timer, the speaking room, and the shared component library generated from the Figma design tokens.
Knowing when someone has finished speaking
Ready-made voice agents treat about three seconds of silence as the end of a turn. That breaks an IELTS speaking test. One part asks the candidate to talk alone for one to two minutes, and a nervous speaker pauses in the middle of a sentence. Cutting them off ruins the attempt and the score.
The session turns off the vendor's automatic end-of-turn and uses a policy I wrote. The allowed silence depends on the part of the exam: shorter for quick questions, longer for the solo talk, in between for the discussion. If the live transcript ends on a filler or a hanging word, such as "um", "and", or "because", the candidate is mid-thought, so the wait stretches a little further. Extra time is capped per turn, so trailing off on "and" cannot hold the floor forever.
The decision is one function. It takes the exam part, how long the silence has lasted, the transcript so far, and how much extra time is already used. It does no clock-reading and no network calls, so the exam behaviour can be tested without starting a live audio session.
Keeping the cost and the product in check
The AI bill shaped the design from the start. If a PDF already contains real text, the import uses one vision pass per section, on the order of 12 to 16 model calls for a whole book. If the file is a flat scan with no text, it falls back to slower page-by-page reading. Before either path, a cheap non-AI pass reads page headers, finds where each paper starts, and drops the page ranges for papers that are switched off. Disabled content never reaches a paid model. Repeated instructions are marked so the provider can cache them. Charts are cropped from the page layout instead of being sent to a model, so figures cost no tokens.
Other limits exist for a specific failure. Two workers cannot claim the same book, and a stuck job fails after an hour instead of sitting on “Extracting…” forever. Rendering a PDF page runs in a throwaway subprocess, because one image decoder crashes on real book scans and would otherwise take the whole worker down. In the browser, answers save every 10 seconds, and only when they have changed.
Merges are gated. The check I rely on most confirms that the backend's request definitions and the Python models still match, and fails the build when they drift. That is the bug that otherwise shows up live as a field that quietly arrives empty. Another fails on a styling class that compiles to nothing: a typo passes the type checker, passes the build, and then simply does not style anything.
A tradeoff I kept
The silence limits are educated guesses. I could not find published pause tolerances for IELTS, so I set them from the shape of each exam part. They still need to be checked against accented, hesitant speech, which is exactly the group most likely to be cut off, and the group I have no measurement for. The policy is a single function and the numbers are named constants, so tuning them later is a one-line change rather than a change to the audio loop.
Books are imported one at a time. A full book takes minutes of processor time, and two pipelines fighting inside one process was worse than making the second upload wait. Chart detection reads the drawing instructions on the page. A flat scan is one image and has none, so those books do not find charts on their own. A reviewer can upload the figure by hand, and it is included when the test is published.