This is a submission for the Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle
What I Built
Walk into any medical shop in Chennai and look behind the counter. There are a couple of thousand strips, bottles and inhalers, bought from two or three distributors, each with a batch number and an expiry date printed somewhere tiny.
Here is the part most people outside the trade don't know. Distributors will usually take expiring stock back, but only until a cutoff. On the invoice it says something like "Expiry returns accepted up to 90 days before expiry against original invoice." Miss that window by a week and the strip is yours to throw away.
Nobody misses it on purpose. The owner is billing customers from 8 AM to 10 PM. Checking 2,000 expiry dates against how fast each medicine actually sells is nobody's job, so the cutoff passes quietly and the money goes in the bin.
ShelfMitra does that check for every batch, every day:
- You photograph the distributor invoice. An open-weight vision model reads the table. Plain code then checks every line before anything is saved.
- It predicts which batches will still be sitting on the shelf when the return window closes, using TabPFN-3.5 on the shop's own sales, and sorts the shelf by rupees at risk.
- A durable Temporal workflow watches each batch and reminds the owner at 60, 30, 14, 7 and 2 days before the cutoff, but only if that batch is at risk.
- One tap writes the return request to the distributor as a WhatsApp message, in Tamil, Hindi or English.
- The owner can just ask. "இந்த வாரம் எதை திருப்பி அனுப்பணும்?" (what should I send back this week?) or "Brufen 100 aur mangwaun?" goes to an agent that checks the numbers before it answers.
Who pays: the owner of a single medical shop, the kind with one billing PC and no IT person. I'd charge ₹299 a month. The pitch is one sentence: if it saves one box of inhalers from the bin, it has paid for itself.
Demo
| Live app | shelfmitra.onrender.com |
| Website | shelfmitra.vercel.app |
| Code | github.com/vignesh2027/shelfmitra |
| 60 s video | shelfmitra-demo.mp4 |
| Eval | eval/results.md |
No signup needed. You land in a demo shop, Lakshmi Medicals, Mylapore, and everything in it is live: the open models, the invoice reader and a Temporal watch on every batch. Create a free shop to get a private, empty one. It runs on Render's free instance, so if nobody has visited for a while the first load takes about a minute while it wakes up.
One honest note about that live demo: TabPFN-3.5 peaks at about 2.3 GB of RAM while scoring on CPU, and the free instance has 512 MB. So the public demo scores with the straight-line rule, and the header says Rule only instead of pretending. Every TabPFN screenshot below comes from the full app on my machine, and the benchmark further down shows exactly what TabPFN adds over that rule.
Here is the full app with TabPFN running. The shelf is sorted by money at risk rather than by date. The Asthalin inhaler is only 12 units, but it has 19 days left to return and an 86% chance of still being there, so it sits at the top:
Reading an invoice photo. All 8 lines pass the checks, and brand names are matched to their ingredient (Montair LC becomes montelukast + levocetirizine):
The whole app switches to Tamil or Hindi, including the agent and the message to the distributor:
Code
vignesh2027
/
shelfmitra
Catch medicines that will expire unsold while the distributor still takes them back. Tamil, Hindi, English. Open-weight models, TabPFN-3.5, Temporal.
ShelfMitra
Sell it before it expires.
Expiry rescue for Indian medical shops, in Tamil, Hindi and English.
Live app · Website · 60 s demo video · Run it · How it works · Eval · Crash demo · Limits
The problem
A neighbourhood medical shop buys from two or three distributors. Most distributors take expired or near-expiry stock back, but only until a cutoff, usually 60 to 90 days before the printed expiry. After that the strip is the shop's loss. Nobody has time to check the expiry of 2,000 batches against how fast each one sells, so the cutoff passes quietly and the medicine goes in the bin.
ShelfMitra does that check for every batch, every day, and tells the owner which ones to send back while the distributor will still take them.
What it does
- Reads the distributor invoice from a photo. An open-weight vision model (Qwen)…
MIT. Python, FastAPI, vanilla JS. Three commands to run it locally, 9 tests (Temporal time-skipping tests for the batch watch, plus account isolation tests).
How I Built It
The one rule: the model reads, code decides
Every number the owner sees is a number they might act on with real money, so I split the work strictly:
| Job | Who does it |
|---|---|
| Copy the invoice table from a photo | Qwen (open-weight vision model) |
Is D2241 a plausible batch? Is 03/27 a real expiry? Does qty x rate = amount? |
Python |
| Will this batch still be on the shelf at the cutoff? | TabPFN-3.5 |
| Return now, stop reordering, watch, or fine? | Python, from TabPFN's risk and the days left |
| Write a polite return request in Tamil | gpt-oss-120b writes a template once; Python fills every number |
| Understand "Brufen 100 aur mangwaun?" and pick a tool | gpt-oss-120b |
Any invoice line that fails a check is flagged for the pharmacist instead of guessed. An expiry of 03/27 becomes 31 March 2027, because medicines expire at the end of the printed month, and that is code, not a model's opinion.
Does TabPFN actually beat a simple rule?
Before I trusted it, I built a benchmark. There is no public Indian pharmacy sales data that I could use, so I took a public dataset of six years of real daily sales from one pharmacy across eight drug classes (Pharma sales data on Kaggle, CC BY-NC 4.0). I dropped simulated batches into that history, each with a random quantity and a random number of days to its cutoff, and asked one question: will more than 10% of this batch still be unsold at the cutoff?
I trained on 2014 to 2017 and tested on 2018 to 2019, with five seeds per size.
| Method | Training batches | ROC-AUC | F1 |
|---|---|---|---|
| Straight-line rule (sales rate x days left) | none | 0.903 | 0.849 |
| Logistic regression | 500 | 0.908 | 0.863 |
| Gradient boosting | 500 | 0.922 | 0.858 |
| TabPFN-3.5 | 50 | 0.895 | 0.833 |
| TabPFN-3.5 | 100 | 0.909 | 0.849 |
| TabPFN-3.5 | 200 | 0.920 | 0.858 |
| TabPFN-3.5 | 500 | 0.949 | 0.882 |
This surprised me. The dumb rule is really good, 0.903, and with 500 examples logistic regression barely beats it. TabPFN passes the rule at about 100 examples and is clearly ahead at 500. So ShelfMitra uses TabPFN when it can and falls back to the rule when it can't, and the header tells you which one is running. I'd rather show that than pretend the rule doesn't exist.
TabPFN also does a second job. When the owner asks about an order, the agent builds a hypothetical batch and scores it. That turned up a bug I'll come to in a minute.
Temporal: I killed the worker, and nothing was lost
A batch can sit on the shelf for a year. The shop PC will restart many times in that year. A cron job or a setTimeout would quietly forget, and the whole point of the product is to not forget.
So each batch gets its own Temporal workflow. It sleeps on durable timers, wakes at fixed checkpoints, re-scores the batch with fresh sales, and reminds the owner only if the batch is at risk. It ends early as soon as the owner taps Returned or Sold.
To prove it, I wrote a crash demo. A shop "day" lasts one second, so a 70-day window plays out in about a minute. It kills the worker with SIGKILL right after the first checkpoint and stays down while the next checkpoint falls due:
The Temporal UI shows two workers on that one workflow, the one I killed and its replacement. The overdue checkpoint ran as soon as a worker came back. Activities retry with backoff and no attempt limit, so a flaky model call or a dropped connection delays a reminder instead of losing it.
In the running app it looks like this. The demo shop's Dolo 650 batch has 413 days to its cutoff, so its watch is a single durable timer that will sleep for 11 months and 19 days before the first check:
Accounts, so it's a product and not a demo
Visitors share the demo shop. Signing up creates a private shop with its own SQLite database. Passwords use PBKDF2, sessions are signed cookies, every query is scoped to the shop in the session, and the endpoints that spend model credits are rate-limited. A test checks that shop B can't see shop A's stock. If you already use billing software, you can export sales as CSV and import them. The importer matches columns loosely, because every billing package names them differently.
Things I got wrong
-
The Tamil message came back cut off mid-sentence. gpt-oss reasons before it answers, and on Tamil it spent most of my token budget thinking. The API said
finish_reason: "length"and my code didn't check. Now a truncated reply counts as a failure and is retried with more room, and the finished template is cached so it happens once per language, not per message. - Every model call took 76 seconds. I blamed Groq. It was my network: the HTTP client tried IPv6 first, waited for it to time out, then fell back to IPv4. Forcing IPv4 took calls to 1 to 3 seconds.
-
The agent answered a Tamil question in English. My tool returned English labels like
return it now, and the model copied them. Now the tool returns the action already in the owner's language. -
The agent showed a shop owner
stop_reorderand0.862inside a markdown table, on a phone. Tool results now arrive as percents, rupees and DD/MM/YYYY dates, so there is nothing to convert and nothing to format. - Order advice ignored the stock already on the shelf. "Should I order 100 more Brufen 400?" came back as 22% risk, while 45 strips were already sitting there unsold. Old stock sells first, so the new batch has to be judged on old plus new. Fixed, it's now 44% for 100, and the agent checks 50 instead (20%) before answering.
- Hinglish questions got Tamil answers. I had left the reply language to the model. Now plain code decides it from the script and a small list of Hinglish and Tanglish words.
-
The small local model copied my prompt into its answer. My invoice prompt described each field, like
"expiry": "MM/YY as printed". The 3B vision model returned exactly that: every expiry came back as03/27 as printed, and the return policy came back as my own description of it. The code checks rejected all 8 lines, so nothing wrong reached the shelf. That is the whole reason the checks exist. I rewrote the prompt so it describes the fields in a list instead of showing them as values, and the same model now gets every expiry right.
Why Does Open Innovation Matter?
The data is the business. A shop's purchase invoices show what it pays each distributor, its margins and what sells in that neighbourhood. Its sales history is close to a record of what its customers take. Owners won't upload that to whatever API is cheapest this quarter, and they are right not to. With open weights, the same models can run on the shop's own PC: set SHELFMITRA_LLM=local and ShelfMitra uses Qwen through Ollama instead of a hosted endpoint. TabPFN-3.5 already runs locally on CPU.
I tested that on my own laptop, an 8 GB M1 Mac, with nothing hosted:
| Same sample invoice | Hosted (Qwen 3.8 27B on Groq) | Local (qwen2.5vl:3b on Ollama) |
|---|---|---|
| Time | 3.5 s | 104 s |
| Expiry dates right | 8 of 8 | 8 of 8 |
| Batch numbers right | 8 of 8 | 7 of 8 (CTZ7712 read as CTZ77712) |
| Return policy read | yes | yes |
So local works, but it is slower, and a 3B model makes the kind of mistake that no format check can catch: CTZ77712 is a perfectly valid-looking batch number. That's why every invoice is shown to the pharmacist as a table before it goes onto the shelf. The local agent on qwen3:4b was too slow on 8 GB to be pleasant, more than ten minutes for three questions, so for now the realistic setup is local invoice reading overnight and hosted open models for chat. A shop PC with 16 GB would change that.
The unit economics work. I measured real token counts on the hosted open models:
| Task | Model | Tokens in / out | Cost |
|---|---|---|---|
| Read one invoice photo | Qwen 3.8 27B (vision) | 1,951 / 984 | about $0.0055 |
| Answer one question | gpt-oss-120b | about 2,300 / 200 over 2 to 3 calls | about $0.0005 |
| Score a batch | TabPFN-3.5 on CPU | n/a | free |
A busy shop that photographs an invoice every day and asks 100 questions a month costs about $0.22 a month in model calls, around ₹20. At ₹299 a month that leaves room for hosting and support. On a closed frontier API, the per-shop cost could easily be several times higher, and I'd be one price change away from the business not working.
Tamil and Hindi are first-class, not an afterthought. The owner should be able to ask the way they talk, including Tanglish and Hinglish typed in English letters.
Where a closed model would win, honestly: blurry, handwritten or crumpled invoices. A big closed vision model would probably read those better than a 3B model on a shop PC. That's why every line goes through code checks and anything doubtful goes to the pharmacist rather than straight onto the shelf.
My Agent Session
I built ShelfMitra with an AI coding agent. I picked the problem, the name and the rules: the model reads and code decides, no discount advice on prescription medicines, and honest numbers in the post. The agent wrote most of the code and the eval harness, and I reviewed and steered it. The bugs above came from actually running it, mostly from screenshots that looked wrong.
Prize Categories
Best Use of TabPFN. TabPFN-3.5 is the risk engine. It scores every batch on the shelf, and it scores hypothetical orders for the agent's what_if_order tool. It is benchmarked against a straight-line rule, logistic regression and gradient boosting on real pharmacy sales with a time-based split (table above), and the app falls back to the rule, visibly, when TabPFN isn't available.
Best Use of Temporal. One durable BatchWatch workflow per batch, with checkpoint timers that span months, signals for the owner's decisions, queries for live status in the dashboard, and retry policies that never give up. It survived a SIGKILL mid-watch with every checkpoint intact (crash demo above). It also has time-skipping tests.
Best Use of Render. The live app at shelfmitra.onrender.com is one Docker service on Render running three things together: the FastAPI app, a Temporal dev server and the Temporal worker. Every visitor's batches get real durable workflows on Render, which you can see in the dashboard's Watching every batch panel:
The public demo is on the free instance, so it uses the rule engine and keeps its state on the instance's temporary disk, which resets when it restarts. The repo also has a render.yaml blueprint for the paid setup a real shop would use: a standard instance with enough RAM for TabPFN-3.5, and a persistent disk for each shop's database and Temporal's state, so reminders survive deploys.



























