/ 01
The problem
Case records exported from older police systems are messy. The same accused person shows up as 'RAJESH kumar', 'Rajesh Kumar' and 'Rajes Kumar' in different files. Inconsistent names make records hard to match and search, and cleaning them by hand is slow work that nobody tracks.
Most case files never reach a system at all. FIRs and case diaries are handwritten, often in Hindi, Bengali or another regional script, so the information in them stays on paper.
The sprint version solved the first problem but wasn't safe to leave online. The Gemini key shipped to the browser, one API route called Gemini for anyone who asked, guests could sign in without an account, and a crime-statistics page rendered HTML written by the model.
/ 02
Approach
I designed the app around the workflow a data-entry officer already has, which is a spreadsheet. You upload a CSV, see it as an editable table, run an audit, review the suggested fixes, and download a clean file. Nothing is changed without a person approving it.
For names I used two stages. Fast, predictable rules catch what rules are good at: digits, stray symbols, double spaces, bad capitalisation. Gemini then suggests spelling corrections, and has to explain each one. Every accepted fix is stamped into the row with who changed it, when and why.
The rebuild added a second way in. An officer photographs or scans a handwritten case document, and Gemini reads it into the same 14-column schema, one record per accused. It writes names in English while keeping the original-script spelling alongside, and flags any field it couldn't read with confidence. The officer checks each record beside the page before it joins the dataset.
I also took out what I couldn't defend. Guest sign-in and the crime-statistics page are gone. Every AI call now goes through the server, which checks the officer's sign-in and verified email and counts the call against a usage limit before Gemini sees anything.
/ 03
What I built
Handwritten document OCR
Drop in JPG, PNG, HEIC or PDF scans of FIRs and case diaries, up to 4 MB each. Records come back field by field, with the accused's name in its original script kept for checking and low-confidence fields highlighted. You can edit, untick or remove a record before adding it.
Two-stage name audit
A rule engine scores each name out of 100, then Gemini suggests corrections with a reason for each. You can apply them one at a time or all at once.
Audit trail in every row
Applying a fix writes the date, the officer's email, a 'Corrected' status and a 'Corrected X to Y' remark into that row. Scanned rows are marked 'Imported via OCR', or 'Needs verification' if any field was uncertain.
CSV in, clean CSV out
A quote-aware parser checks the file against the 14 expected columns and shows every cell as an editable field. Exports quote values properly and neutralise spreadsheet formula injection, so a cell can't run as a formula in Excel.
Ask AI about your data
Ask questions in plain English, such as which accused are absconding by station, and Gemini answers using only the loaded file. The server accepts the question alone, and fixes the model settings and prompt size itself.
Verified, per-officer accounts
Email or Google sign-in, a verified email before the console opens, 12-character passwords with a live checklist, and a password-reset flow. Firestore rules let each officer read and write only their own files.
/ 04
How it works
- Browser
- Next.js 15.5
- TypeScript
- shadcn/ui
- Device gate
- Identity & storage
- Firebase Auth
- Verified email
- Firestore rules
- Server
- ID-token check (jose)
- Usage limiter
- Genkit flows
- OCR + query routes
- AI
- Gemini 3.5 Flash
- JSON-schema output
- zod-typed flows
/ 05
Hard parts
- 1
Rules first, model second
LLMs aren't consistent about simple formatting, and they cost a call per batch. So a deterministic validator runs first. It starts every name at 100 confidence and deducts points for each problem, and only the results go to Gemini.
// validateBasicRules, simplified let confidence = 100; if (name.length < 3) confidence -= 30; if (/\d/.test(name)) confidence -= 40; if (/[^a-zA-Z\u0900-\u097F\s.\-]/u.test(name)) confidence -= 30; if (/\s{2,}/.test(name)) confidence -= 15; if (!isTitleCase(name)) confidence -= 20; return { valid: confidence > 70, confidence }; - 2
Locking down a public demo
Server actions are public endpoints, so hiding a button proves nothing. Each audit action and API route now verifies the caller's Firebase token itself and refuses guest and unverified accounts. Anyone can sign up, so on top of each officer's hourly and daily limits there's a shared cap across all users. Counts live in server memory, so a Google Cloud quota stays as the hard backstop.
// rate-limit.ts, simplified const LIMITS = { query: [{ limit: 30, per: 'hour' }, { limit: 200, per: 'day' }], ocr: [{ limit: 40, per: 'hour' }, { limit: 200, per: 'day' }], audit: [{ limit: 10, per: 'hour' }, { limit: 50, per: 'day' }], }; // Sign-up is open, so every Gemini call also counts here const GLOBAL = [{ limit: 600, per: 'hour' }, { limit: 3000, per: 'day' }]; - 3
Reading handwriting without inventing it
A model asked to fill a form will happily fill it. The OCR prompt says to leave unreadable fields empty and never guess, and a response schema makes Gemini list every field it was unsure of, using only the real field names. The server then cleans up the reply before the browser sees it. Uploads stop at 4 MB because Vercel rejects request bodies over 4.5 MB. A busy model (503) is retried with backoff, but a used-up quota (429) fails straight away, because waiting won't fix it.
- 4
Turning AI output into table edits
Gemini returns a list of names, not cell positions, so each suggestion has to be traced back to its row and column before it can be applied. The rule-based pass now keeps row numbers, but Gemini's suggestions are still matched back by value across the three name columns, so identical names in different rows collapse to the first match. Asking the model to echo back a row and column for each suggestion is the proper fix.
- 5
Bias is a correctness bug
To make the model respect regional naming conventions, I gave it examples of 'wrong' names. Looking back, many of those examples are real names that combine different communities or regions. In a police system that's a bias risk, not a style choice. The list is still in the prompt. The fix is to limit the model to formatting and spelling, so it never 'corrects' a name just because the combination looks unusual.
/ 06
Design
The rebuild swaps the warm near-black and pale teal for a cooler, more official look: a near-black slate, one muted steel-blue accent, green for applied changes and amber for anything still waiting, such as the 'Corrections pending' count. Geist Sans and Geist Mono keep dense tables readable, corners are tight, and the national emblem with a plain wordmark sets the tone.
A new landing page sets out the problem, the six capabilities, a four-step workflow and the governance rules before it asks anyone to sign in. Its hero shows a sample of the console, including a scan turning रमेश कुमार यादव into Ramesh Kumar Yadav. The console is a dense dashboard with a row of key numbers, a case-file panel, and an editable table with sticky headers and row numbers.
The console is desktop-only on purpose, because wide case tables don't work on a phone. Middleware sends phones and tablets to a block page, and a browser-side check catches iPads that report a Mac user agent. The password-reset page stays open on mobile, because that's where people open the email.
- Slate#0A0C10
- Surface#101318
- Text#DBE0E6
- Steel blue#3E80CC
- Applied#389F6F
- Pending#E79F23
/ 07
Outcome
- The sprint shipped a working end-to-end flow in about 38 hours: upload, edit, audit, AI corrections, audit trail and export, plus a per-user file library and AI Q&A.
- The rebuild is live on Vercel at a new address. Anyone can create an account, but the console only opens after email verification, and every AI feature is metered per user and across all users.
- Around 7,100 lines of app code, not counting the generated UI components, with three Genkit flows and two authenticated API routes. Guest sign-in and the crime-statistics page were removed rather than patched.
/ 08
What I learned
- Treat every server action as a public endpoint. AI features became safe to leave online only when each one checked the caller's token itself, rather than trusting the UI to hide them.
- Cutting code can be the security fix. Removing guest access and the page that rendered model-written HTML closed more holes than patching them would have.
- Measure before you claim. The next step is a small labelled set of messy names and handwritten scans with an eval script, so correction and OCR accuracy become numbers rather than impressions.
- In public-sector tools, a human approves every change. Suggest-and-approve with an audit trail now covers OCR imports too, which keeps AI an assistant rather than an authority.
/ 09
Screens




