hkboat
AI Engineering
A boat-licence question bank built by handing the model the printed answer key instead of trusting what it already knew — then deliberately un-built as a business when the search data said the market was not there.
01The problem
Anyone who wants to legally operate a pleasure craft in Hong Kong has to pass the Marine Department's Class 2 written exam: two papers, navigation and marine engineering, roughly forty multiple-choice questions each, sixty per cent to pass each part. The material that actually matches the exam exists almost entirely as scanned PDFs and printed course notes. No text layer, no search, no way to drill the topic you keep failing.
So candidates flip through a stack of photocopied mock papers, cross-reference a printed answer table at the back of each set by hand, and spend most of their time re-doing the questions they already know. Course providers solve this by bundling the bank into a paid course, which is fine if you want the course. The pain was mundane and repetitive: no practice by topic, no instant explanation, and no record of what you got wrong.
02What I built
A mobile-first web app that turns those scanned papers into a searchable, drillable bank. The interesting engineering is not the app — it is the ingestion pipeline that reads image-only pages with a vision model, extracts each question into structured data, and pairs it with the correct letter by reading the printed answer-key page inside the same call. The finished bank ships as a compiled module inside the bundle, so practice works instantly and offline, with a Postgres copy of the same rows for querying.
- Topic-by-topic practice across 17 subject areas, with instant answer reveal and a written explanation.
- Mock exam mode: shuffled draw, Part A, Part B or mixed papers, marked against a 70 per cent pass line.
- A wrong-answer book that accumulates misses on its own and clears an item once you get it right.
- Cropped diagram images for the questions that need one — navigation lights, buoys, signal flags.
- A course-notes index covering both papers plus chart and light-signal topics.
- Installable as an app on phone or desktop, with progress kept in the browser so there is no login wall at all.
Drawn, not captured. The rail switches between topic drilling, mock papers and the wrong-answer book; the block in the middle is one question with its four options and the answer revealed, and the panel below it is the written explanation. Every line and mark is illustrative — no exam question is reproduced here.
03As a product
- Who buys it
- Hong Kong licence candidates, who are one-off buyers with one or two months of use each, and boating schools, who would want it bundled as a student tool rather than as a competitor.
- Value
- The exam content is a fixed, finite corpus. Digitising it once converts a stack of photocopies into unlimited targeted drilling with explanations, which is the highest-leverage part of exam prep, without buying a whole course to get it.
- Positioning
- Incumbent providers sell the bank inside a course package, with public list prices ranging from about HK$980 to HK$2,600 at the time of research. Free question-bank apps already exist on both mobile platforms. The differentiators were topic segmentation, per-question explanations and real cropped diagrams — a better product in a category where the price floor was already zero.
- Status
- Deliberately not commercialised. It runs on a Vercel subdomain in personal use with no login and no revenue; the domain chosen for the commercial version was never bought once that plan was cancelled. Auth, pricing and the payment entry points were removed the same day the decision was made.
04How it works
- Stack
- Next.js 16 with the App Router, React 19, Tailwind, TypeScript and a web-app manifest; Supabase Postgres in an isolated schema with row-level security and SQL migrations; Vercel hosting; an ingestion pipeline of Node scripts plus Python for image work, with vision extraction driven from the command line against strict output schemas.
- Shape
- An offline batch pipeline that produces a static artefact, and a reader that never queries at run time. The bank is compiled into the bundle; user progress lives in the browser; the database copy exists for querying rather than for serving.
Width measured — counted from the shipped question file and the ingestion record: 933 deduplicated extractions in all, 503 kept and roughly 430 dropped.Width uniform — page rendering and vision extraction were never counted, so their bands claim no quantity and are drawn at equal width.
The gate is the whole diagram. A question whose answer will not resolve against the printed key never reaches the bank, and the band that stops is very nearly as wide as the band that continues — which is the cost of the rule, and the reason the remainder can be trusted at all.
The decision I spent longest on
How to get the answers right. The scans have no text layer, and letting the model supply answers from its own knowledge produced roughly eighty-five per cent accuracy — useless for exam prep, where a confidently wrong answer teaches the wrong thing.
The fix was to stop treating extraction and answer lookup as two stages. Feeding the question page and the printed key into the same call, and making the model first work out which of the four sub-papers a page belongs to before matching by number, produced exact matches against the printed key. Two smaller constraints made it hold: about three page images per call, because fifteen made the model return empty arrays, and a merge script that refuses any item with a null answer rather than guessing.
05Retrieval architecture
Every hard problem in this project sat on the ingestion side, before the app existed. The scanned pages carry no text layer, so the expensive reading is done once against a strict schema — and what makes the result usable is not how much was recovered but how much was refused.
Read as an architecture, the interesting rows are the ones that stayed empty. An architecture is also what you decline to build, and here two layers were declined on the same ground: the corpus is small, fixed, and already shaped like the question anybody asks of it.
ModelAt index time only. No model runs anywhere in the shipped app, and none is called while a candidate is answering a question.
- Corpus
- Five hundred and three exam questions across 17 topics, compiled into the app bundle as a typed module, with 55 cropped diagrams beside them for the questions that need one. Behind that sit the scanned past papers and the printed answer keys they were read out of. Nothing is fetched at run time; the database copy exists to be queried rather than to serve.
- Ingestion
- A one-off batch, run by hand, never on a schedule. Pages are rendered to 200 dpi images and read about three at a time by a vision model against a strict output schema — fifteen at a time made it return empty arrays — with the printed answer-key pages travelling inside the same call, so that number-to-letter resolution happens in one context instead of as a later join. The model has to work out which of the four sub-papers a page belongs to before it can match by number, because each sub-paper restarts its numbering at 1.
- Index
- None. The bank is a 503-item array that compiles into the bundle. At that size the whole set is in memory the moment the app loads, so an index would buy a build step, a schema and a staleness problem in exchange for saving a scan that already finishes inside a millisecond. The only structure worth having was the topic label, and that is a field on the question rather than a thing built beside it.
- Query
- None. Nothing is parsed, rewritten or expanded, because nothing arrives as language. A tab is already a predicate: part, topic and the wrong-answer list are chosen by tapping them, exam mode is a mode rather than a request, and the app never has to work out what was meant. A search box would have been the one place in the product where a candidate could ask for something and be told the wrong thing.
- Selection
- A predicate over structured fields. Topic drilling filters on part and topic; exam mode shuffles a part-filtered pool and takes the first n; the wrong-answer book is a list of question identifiers held in the browser and sorted by the most recent miss. What comes back is shaped only by that: order is either the source order or the shuffle, nothing carries a score, and so there is nothing for a reranker to operate on.
- Grounding
- The printed key is the ground truth and the model is never allowed to stand in for it. Any item whose answer will not resolve against that key is dropped rather than guessed — roughly 430 deduplicated extractions stayed unpublished for that reason alone, against 503 that shipped. Answers the model inferred from its own knowledge scored about 85 per cent and were rejected outright, because in exam preparation a confidently wrong answer is worse than a missing one.
Present — this layer exists and runs.Absent by decision — the layer is not there, and the sentence beside it is the reason. Every one of the six is answered on every system in this chapter, so the rows can be read across pages.
06Numbers
| Measure | Figure | Basis |
|---|---|---|
| Questions in the shipped bank | 503 | Verified |
| Part A and Part B split | 160 / 343 | Verified |
| Questions carrying a written explanation | 316 | Verified |
| Answers matched to the printed key on the validation page | 7 of 7 | Verified |
| Accuracy of the rejected route, answers inferred without the key | ~85% | Verified |
| Elapsed time, first commit to shipped bank | ~2 days | Verified |
| Manual transcription effort avoided | ~40 hrs | Projected |
Verified — counted from the shipped question file, the topic union in the source, and the project's own dated records; the two-day span is bounded by the single commit at the start and the file stamps at the end. Projected — an order-of-magnitude estimate, not a measurement. It assumes about five minutes per question to retype a Traditional Chinese stem and four options from a scan, look the number up in the printed table and tag it by topic: 503 questions at five minutes is roughly 42 hours. No time study was run.
07Timeline
- 2026-06 Project started. Scaffold, database schema with row-level security, first deploy, and the first 319 questions extracted from two mock papers.
- 2026-06 Monetisation designed the same week: a HK$108 monthly subscription with the content free and only the exam tooling behind the paywall. The payment skeleton was built and never keyed.
- 2026-06 A second source, a 114-page PDF, digitised. The paired answer-key technique found; one section added 184 questions and took the bank to 503.
- 2026-06 Market validation done, commercialisation cancelled, and auth, pricing and payment entry points removed the same day. Rebuilt and redeployed as a personal tool.
08Looking back
What broke
Most of the failures were quiet ones. Batching too many pages into a call returned empty arrays rather than an error, so the pipeline looked like it was working while producing nothing. The four sub-papers inside the second source each restarted their numbering at one, so question-to-answer matching only worked once the pipeline learned to detect set boundaries by watching for the number reset. I also lost a detour to a wrong assumption about my own tooling — a helper reported that the command-line path had no vision support, which turned out to be false; it needed a flag. And the second source was almost entirely engineering content, which is why the bank is skewed 160 to 343 and would need a separate effort to rebalance.
What it changed
Two things, and the second matters more. The first is the extraction pattern: hand the model the answer sheet instead of trusting its knowledge, and refuse any item it cannot resolve. That is the difference between a document pipeline that produces plausible output and one that produces correct output, and it transfers to any scanned-form digitisation job.
The second is knowing when to stop. Keyword data put the entire category at roughly one to two thousand searches a month and trending down about ninety per cent year on year, with free competitors already holding the floor. The choice was another week on payment onboarding, a domain and an auth layer, or cutting the product back to what it was actually good for. I cut it back — replaced auth with browser storage, redirected the pricing routes home, and left the payment code in place as inert dead code. A reversible teardown rather than a demolition, in case the read was wrong.
Where it stands
Live for one user, no login, no revenue, and none intended. The pipeline still works and about four hundred more Part B questions remain extractable from the same source if the volume is ever wanted.