DEV Community

Sourav Bera
Sourav Bera

Posted on AI-assisted

I stopped asking the model to type the PAN. It picks the box. The number stays on this PC.

Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady Submission 🦥

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady

A college portal, a KYC page, and a job form asked for the same person. Name. Father's name. Date of birth. Board, year, marks. The address. I had already typed it into the previous portal. The marksheet was a PDF on the same computer.

The slow part was never the clicking. It was finding the PDF again and hoping I had not swapped two digits.

What I Built

SlothFill is that second typing, removed.

You point it at the PDFs once. Gemma 4 reads them on the laptop, into a profile you check once. After that, one click fills the form. The click does not send it. I still look, and I still press Submit myself. The laziness is the retyping. The part that can go wrong stays mine.

It is for the person in India who fills the same identity into an admission form, a KYC-style form, and a job form, and is tired of doing it carefully by hand. International Sloth Day was 20 October. The project is named for that pace on purpose. The first read on this machine takes the better part of a minute. Every form after that is the steady part.

The sample person in the repo is fictional. Her name is Sana Qureshi. The PAN SLTHF0000A and the Aadhaar XXXX-XXXX-0000 are not real numbers. Real identity documents stay out of git, and the saved profile stays in a file the repo ignores.

Three things the click will not do, even when it is sure:

  • It will not press Submit. The demo buttons stop the submit and say the form was not sent.
  • It will not invent a field that is not in the profile. Preferred course stays on "Select course". A passport number stays empty. A portfolio URL stays empty.
  • It will not type a value the model made up. The model is only allowed to name a profile key. Python looks the value up.

Demo

The forms are up. Nothing on that host can see a document.

Those pages are the static demo. Fill from local profile asks the same origin for /map. On Render there is no model and no profile behind that path, so the button tells you to start the local server. That is the privacy property, visible in the deployed page: the public site can show the form, and it cannot fill it.

The fill runs here:

On a computer with Ollama:

ollama pull gemma4:e2b-it-qat
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python -m slothfill
Enter fullscreen mode Exit fullscreen mode

Open http://127.0.0.1:8787/demo/. Setup reads a PDF. Admission, KYC, and Job each have Fill from local profile.

The Chrome and Edge extension does the same thing on a normal page. Load the extension folder unpacked. The only host it may call is http://127.0.0.1:8787.

Screenshot 1 of 5. Home.


Screenshot 2 of 5. Setup, while Gemma is reading.

Screenshot 3 of 5. Review.


Screenshot 4 of 5. Fill from saved profile. Green fields. Preferred course still on "Select course". The status line says the form was not sent. Drop the image above this line, then delete this line.



Screenshot 5 of 5. The extension & how it works

Code

GitHub logo souravxbera / SlothFill

one-time setup, one-click forms (local, open-weight)

SlothFill

SlothFill fills college, KYC, and job forms from a profile that stays on your computer. You set it up once from PDFs. After that, one click fills a form. The form is never submitted.

Two open-weight models run locally through Ollama. Gemma 4 reads the documents. A fine-tuned Qwen3.5-4B picks which profile field belongs in each box the rules do not already know.

The sample person, Sana Qureshi, is fictional. SLTHF0000A and XXXX-XXXX-0000 are not real identity numbers.

How it works

PDFs  ->  Gemma 4  ->  profile you review  ->  data/profile.json
                                                     |
page  ->  extension or demo button  ->  http://127.0.0.1:8787/map
                                                     |
                              rules, then memory, then the fine-tuned mapper
                                                     |
                              values written into the fields, form left unsent

Everything in that path stays on the machine. The server binds to 127.0.0.1:8787 only. The public pages are static demos. They do not receive IDs, marksheets, resumes, or the profile.

Setup

…

https://github.com/souravxbera/SlothFill

The fill path is small. Rules and memory run first. One JSON call covers whatever is left. The extension writes values through the browser's native setter, so a React input notices, and it never calls submit.

How I Built It

Two models, two jobs, both local, both open weights.

Job Model Where
Read a PDF into a profile gemma4:e2b-it-qat Ollama on this PC
Decide which profile key a leftover label wants slothfill-mapper, a Q4 of a Tinker LoRA on Qwen3.5-4B Ollama on this PC

This PC is a CPU with 16 GB of RAM and no GPU. I did not pull the larger Gemma. The E2B QAT build is 4.3 GB, and Chrome is already open.

Gemma reads the documents

A PDF with a text layer goes through pypdf, then one Gemma call turns that text into profile JSON. A scan, with no text layer, is rendered to page images and sent to Gemma's vision. The user-facing upload is PDF only.

On the sample text PDF, the profile came back exact, including the masked Aadhaar. That call took about 46 seconds. The same page as an image got the name, the father, the date of birth, both classes, and the masked Aadhaar, and it misread the fake PAN as SLTHF00000A, one extra zero. The review page exists because of that miss. You correct the profile once. Forms after that use the corrected file.

Aadhaar is stored masked, last four digits visible. The profile file is gitignored. Until you save one, the demo uses the fictional fixture.

Setup can also take a folder on this computer. It lists the PDFs in that folder, you uncheck anything that should stay out, and a label updates every second so a long CPU read does not look frozen. Subfolders are left alone. The cap is 20 files.

The model names a key. The code types the value.

I tried the obvious version first: ask a model to fill the form. It will. It will also put an identity number into a box marked Passport, because the box looks hungry and the number is sitting right there in the context.

So the mapper never sees a request for a value. A field goes to it as a label, a type, and the options on the page. It answers with JSON:

{"id": "whatsapp", "profile_key": "phone", "transform": "text"}
Enter fullscreen mode Exit fullscreen mode

Python looks up phone. If the key is unknown, or the label is on a list the profile cannot honestly answer (passport, visa, preferred course, portfolio, mother's name, password, OTP), the field stays blank. Gender, category, and state have to match an option that is actually on the page. Dates follow the input: ISO when the input is type=date, otherwise DD/MM/YYYY or DD-MM-YYYY.

The order on a real page is:

  1. Memory. If this exact form was filled before, reuse the saved key. The value comes from the profile as it is now, so a name you corrected shows up on the next visit. A second visit does not call the model.
  2. Synonym rules. "Father's / Guardian name" and the other labels that show up on every Indian form. These do not need a model.
  3. One call for the rest. Leftover fields go together, not one request per box. WhatsApp is not in the rules. The fine-tune maps it to the phone. The first time, that field is marked for a look (confidence 0.86, yellow). The second time, memory marks it sure.

Green means 0.9 or higher. Yellow means look once. Empty means the profile does not know, and the form is better left alone.

The fine-tune, and the baseline it had to beat

The mapping set is short on purpose. About 1,070 training rows, and 165 held-out rows whose wording is not a reshuffle of the training wording. "Mobile number" is in training. "WhatsApp number" is held out. The answer is always a profile key and a transform, never a person's value.

I trained a LoRA of rank 16 on Qwen/Qwen3.5-4B, the 64K model, with Tinker. One epoch. The first pass kept rows grouped by field type, so the harder labels arrived after the learning rate had nearly reached zero. I kept that checkpoint and did not score it. The scored run shuffled the rows with seed 0. Loss fell to about zero by the end of the epoch.

Held-out exact match, temperature 0, one guess each, 165 labels. All three models returned valid JSON on every row.

Model Exact match Where it ran
Tinker LoRA 86.1% (142/165) Tinker
Base Qwen3.5-4B 58.2% (96/165) Tinker
Gemma 4 E2B 51.5% (85/165) This PC, median 3.9 seconds

The fair comparison is the first two rows. Same base family, same sampler, same labels. The LoRA picks up 46 labels the base model misses. Gemma's 3.9 seconds is local CPU time. I am not calling that a latency win over a cloud sampler, and I am not calling Gemma's score a loss on the same hardware. It is a third column, so the document model is in the table too.

The misses I inspected were odd paraphrases ("clan title", "inbox address") or a nearby key, often an empty key. That is the failure I would rather have. An empty box is a form I can finish. A confident wrong identity number is not.

Two training passes plus the scoring came to about $0.40 of the $10 credit.

Gemma stayed the document reader. It lost the mapping test, and on a live form it tried to pour the category into Preferred course. The code already drops those guesses. The mapper job moved to the LoRA.

Getting the weights home

Ollama 0.35.1 on this PC rejects a Modelfile ADAPTER line, so the LoRA could not sit beside qwen3.5:4b. It had to be merged into the full base weights and loaded as one file. The base is about 8.7 GB. Memory was not the problem. The download was. One connection from Hugging Face ran at about 70 KB/s. Eight connections together reached about 1 MB/s.

A retry then appended a second copy of a piece onto a piece that was already the right length. That slice held NaNs in three MLP tensors. Quantize stopped on them. I downloaded that range again, spliced it back, and a NaN scan of the shard was clean. The repaired model quantized to Q4_K_M, 8,024 MiB down to 2,573 MiB. ollama create slothfill-mapper accepted it.

A cold map of three fields took 14.7 seconds, then about 5 seconds. WhatsApp number returned phone. Passport number returned an empty key. Father's name returned father_name. The product prompt, all three fields in one JSON call, returned the same three answers in about 30 seconds. The mapper runs at temperature 0. Document reading still goes to Gemma, at the sampling settings Gemma's guide recommends.

A Google Form that filled nothing

The extension's first look at a Google Form reported zero filled fields. The question text lives on a heading, pointed at by aria-labelledby, and the choices are role="radio" buttons, not <input type="radio">. The collector was reading the widget, not the question.

It now reads the heading and clicks those choices. A local copy of that form filled the name and left 1st, 2nd, 3rd, and 4th year empty, because the profile has a passing year from a marksheet, not "which year are you in". I would rather leave a year blank than click 2024 as if it were an answer.

Ordinary HTML fields are filled, including React inputs, through the native value setter. Shadow DOM and cross-origin iframes are outside this version.

Tests run in GitHub Actions on the rules path. That job does not start Ollama. A published release zips the extension.

Built on open source: Gemma 4 (Google DeepMind), Qwen3.5 (the Qwen team), Tinker and tinker-cookbook (Thinking Machines), Ollama, llama.cpp, pypdf, pypdfium2, and FastAPI.

Why Does Open Innovation Matter?

A closed form-filler wants the PDF. That is the whole product for them, and it is the thing I will not upload. The pages at slothfill.onrender.com are the forms and nothing else. Fill is not implemented on that host. An Aadhaar scan, a PAN, a marksheet, and a resume are enough to impersonate someone. "We don't train on your data" is a policy. Loopback is a property. This server binds to 127.0.0.1. The extension's only permission is that host. Turn the network off after the models are downloaded and the fill still runs.

Open weights are also why the mapper could change. Gemma was good enough to read a page and not good enough to map leftover labels. I could measure that, train a small LoRA on a different open model for about $0.40, and swap the mapper without sending a single document to the training run. The training rows are labels and keys. There is no person in them. Renting a closed model would have left me with a prompt I could not fix, and a per-form bill for something a student fills in a burst of portals and then doesn't touch for months.

The ongoing cost of a fill is electricity. That is what makes "set it up once" true. A tool you pay for per identity field is a tool you will not use on the fourth portal.

My Agent Session

The working log is in the repo, written while the project was built:

https://github.com/souravxbera/SlothFill/blob/main/SKILLS.md

It is the agent session for this entry. It has the decisions and the breaks: the unshuffled epoch I refused to score, the download that doubled a file, the NaNs, the Google Form that filled zero fields until the heading was read, and the rule that the model names a key. It is notes, not a polished transcript. I did not run DevRelay. The file is the record.

The Lazy Payoff

I used it this week on the fictional profile, on the three demo forms.

  • Admission filled 21 fields in one click. Preferred course stayed on "Select course", because a course is not on a marksheet.
  • KYC selected the gender and wrote the date of birth in the format that input expects.
  • The job form split the name into Sana and Qureshi. WhatsApp was not in the rules, so the fine-tune mapped it to the phone and marked it for a look. The second fill of the same form reused memory, and nothing on that form still needed a look.
  • Pressing the submit button on the demo does not navigate. The status line says the form was not sent.

The sample text PDF took about 46 seconds to become a profile. That is the slow half, and it happens once. The steady half is the next portal, which is a click, a glance at anything yellow, and a submit I still do myself.

What this version leaves alone

  • It does not fill shadow DOM or a form inside a cross-origin iframe.
  • It does not watch a folder. You point it at a path and press read.
  • A scanned PAN can be off by a digit. The review page is the check, and it only works if you actually look.
  • The 86.1% is exact match on held-out labels, scored on Tinker. The local Q4 then passed a three-field smoke test with the same answers. I have not re-scored all 165 rows on the quantized file.
  • Training labels are synthetic phrasing. The demo person is synthetic too.

Prize Categories

Solo entry. DEV: souravxbera.

I am entering the overall prize, Best Use of Gemma, and Best Use of Tinker.

Overall

The theme is the product. The first read is slow, on a CPU laptop, and every form after that is the hour I don't spend retyping a marksheet. Open-weight models are the reason that laziness doesn't require uploading an ID. The brake is part of the entry: the model cannot type a value, and the extension cannot press Submit.

Best Use of Gemma

Gemma 4 E2B (gemma4:e2b-it-qat) runs locally and does the job nothing else in the project can do: turn a PDF into a profile. On the sample text PDF the profile was exact, in about 46 seconds. On the same page as an image it missed one digit of a fake PAN, so the product shows you the profile before any form uses it. I also scored Gemma as a mapper, 51.5% (85/165) on this CPU, and left that job to the fine-tune. Gemma kept the job it was the right size for. The larger E4B build stayed unpulled. This machine has 16 GB and no GPU.

Best Use of Tinker

The category asks for a fine-tune with a clear gain over a baseline.

  • The task is exact match of profile key and transform, temperature 0, 165 held-out labels whose phrasing differs from training. "WhatsApp number" was not in the training set.
  • The baseline is the same base model, Qwen/Qwen3.5-4B, sampled the same way on Tinker. 58.2% (96/165) to 86.1% (142/165). JSON was valid for every row, before and after.
  • The run I scored is the shuffled one. An earlier ordered pass was kept and not used for the number above.
  • Spend for training and scoring was about $0.40.
  • The adapter is merged, quantized to Q4_K_M, and loaded in Ollama as slothfill-mapper. A smoke test returned phone for WhatsApp, an empty key for passport, and father_name for father's name. The mapper in the product runs at temperature 0, and it returns keys. The code types the values.

Thanks for reading. If you fill the same twelve answers into every portal, the repo is the place to start, and the profile never has to leave your machine.

Top comments (0)