← All notes

I wrote that my app has no AI. I had to change that

Sławek Łukjanow · · Not by Prompt Alone · episode 9 · Czytaj po polsku

On the Zakupomat website there's a list of things the app deliberately doesn't have. Flyers, hunting for the cheapest shop, multiple lists, budgets... And one more item I was a bit proud of: "AI in the app". In times when every shopping list has an "AI assistant", we don't. Free, no ads, no chat that costs money with every question.

On Saturday I changed that line. Now it says: "AI chat in the app". One word of difference, and a whole story behind it 😉

A comment that sat there for a month

At the beginning of October, under one of my LinkedIn posts, a reader wrote that Zakupomat meets her expectations, but if she had to improve something, it would be grouping products. "Sometimes it's easier to pick a category like "dairy" or "cleaning" and choose a specific product from it". And right away she added that she doesn't really miss it that much. #simplicityrules

I logged it in the issues as a weak signal. Because categories are the classic direction in which shopping lists bloat. Exactly the thing that annoys people in other apps.

But the idea kept coming back. When your product base grows, scrolling through "Stock up" looking for what's missing in the bathroom stops being fun.

A dictionary? No, thanks

The first idea was the "proper" one, without AI: a dictionary name → aisle. Milk → dairy, dish soap → cleaning. Build it once, fix it over time.

Except that at the time the base had over 2000 different product names. In several languages. With typos, abbreviations, brand names, "milk for Zosia", "ost" (Swedish for cheese), "danio" (a Polish dessert brand), "plasters"... Plus lists that aren't about groceries at all - dental supplies, LEGO bricks, a trip to the DIY store. A dictionary that works well from day one would take weeks of typing. And it would still be full of holes in the long tail.

I simply didn't like that direction. Categories that put yogurt into cleaning products once every few items - that's a quality I couldn't accept. And a hand-maintained dictionary is work that never ends.

So I figured out how to do it differently.

One base for everyone, AI only for new names

The assumptions I wrote down in the issue:

  • A fixed list of categories in the app. It came out at 41 - more detailed rather than less: bread separately, pasta separately, rice and grains separately. Names in 26 languages, in the app's language, not the products'. Products in Polish but the app in Swedish? Categories will be in Swedish.
  • One shared base of mappings "product name → category", with no information about whose name it is. "Milk" gets a category once, for all households. Deleting a household doesn't touch this base, because the entries don't point to anyone.
  • AI is asked only about names the base doesn't know yet. The first "milk" costs a fraction of a cent, every next one - zero.
  • The whole existing base at the start, but smart and cheap - without asking about duplicates.

At first I wanted a Polish model - Basal by Remigiusz Kinas, a decision model made exactly for tasks like this. Unfortunately it needs self-hosting, and it only does well in Polish and English. And I have 26 languages.

So for the classification I chose Jev by TypeSafe AI. Not a chat, not a big do-everything model, but a service for exactly this kind of job: you give it a list of possible answers and a question, you get a choice and a confidence. The price? $0.042 per million input tokens, output for free. Yes, per million 😮

The devil is in the details

Claude wrote the code, but a few things mattered more to me than the code itself.

Normalizing names. Before a name goes into the base (and to the AI), it goes through a sieve: lowercase, no "x2", "500g", "30%", no brackets and no trailing "for ...". "Milk 2% (large)" and "milk" are one entry. And "milk for Zosia" is simply "milk" - a child's name doesn't need to fly off to any service in the USA. The exception: if only one word is left after cutting, the "for" stays. "Food for the cat" without "for the cat" is just "food" 😂

"Mąka" is not "maka". A test against the real database showed that the database, when comparing text, treats the Polish "ą" and "a" as the same letter, and Python doesn't. Two different names collided in one key. Now we compare the key byte by byte. A small thing that makes a difference with several languages full of diacritics.

Batches of 20. On a sample of 213 names we tested two variants: one name per request, or a batch. A batch of 20 was more accurate - 97% versus 95%. The context of other products helps the guessing. 50 per batch no longer fit in the limit. The cost of the whole sample? About one cent.

Important - the sample was made up, modeled on real lists. Real names didn't go anywhere until I changed the privacy policy. First the policy with a new data processor, a data processing agreement and a ban on training on our data, and only then the key on the server.

The confidence threshold on read, not on write. The AI returns a category and how sure it is. Below the threshold the product lands in "Other". But we save everything and apply the threshold only when displaying. That way changing the threshold is one number in the config, not asking the AI about the whole base again.

And good thing, because the threshold had to change the very same day.

The migration: one loop, one key

There was no separate "migration" script. On the server there's one loop - every 15 seconds it collects names without a category from all households and asks Jev about them, 20 at a time. The same for a new product as for the whole base. After saving, the server sends an event and the phones refresh by themselves.

So the migration started the moment the key appeared on the server. Not when the code was deployed, but when I decided.

The result: 2079 names in 3 minutes. And mostly because the loop waits 15 seconds between rounds - Jev answered for 200 names in about 3 seconds. The TypeSafe console for that whole day - samples, migration and new products together: 367 requests, 5.4 million tokens, $0.18. Yes, 18 CENTS. Most tokens aren't eaten by the names themselves, but by the list of 41 categories that goes into every request.

The threshold set on the sample at 0.6 left 21% of names in "Other". Too many. We went through the 0.5-0.6 range on real data: dill → fresh herbs, pudding mix → flour & baking. Mostly right. Below 0.5 the weird stuff started: cola → fruit, tin foil → cheese 😂 I went down to 0.5. Now 97.7% of products have a category, and 299 names are left in "Other" - mostly short and ambiguous ones.

And even above the threshold not everything was the way I'd like. My favorite: "soda oczyszczona" - Polish for baking soda - landed in drinks 😜 For a model that knows English better than Polish baking, "soda" is a fizzy drink, of course. That's why a household has to be able to fix it for themselves.

AI will be wrong. What then?

A model makes mistakes and will keep making them. What matters more than how often is what happens next. For me it comes down to three rules.

  • A user's correction doesn't change the result for everyone. In the product edit screen you pick a different category from the same fixed list of 41. You can't create new categories. The choice applies only to that household. It doesn't fix the shared base, because one household keeps cheese in "Other" for some reason of its own and shouldn't impose it on everyone. In the admin panel I only see aggregated pairs "name, was → is", without households. When many households correct something the same way, I fix the base by hand.
  • The off switch belongs to the user, not just to me. "Automatic categories" can be turned off for the whole household. Off - that household's names don't go anywhere. Categories the base already knows keep working, because that's a local read.
  • An action that changes everything at once gets a confirmation and an "Undo". Under "More" you drag categories into the order of your shop, and "Stock up" follows it. And there's a button "Arrange products in the shop by category" that rearranges the whole shop order in one move. Manual ordering within a category stays. I came up with this button after the first TestFlight test and added a condition: "definitely with a confirmation, so nobody accidentally messes up their list". There's a confirmation and there's "Undo" for 10 seconds.

It's a trial

On the website and in the privacy policy I wrote it plainly - this is a trial. If Jev starts costing too much, I turn the automation off. The base of mappings and the manual choices stay, categories keep working, only new names stop getting them automatically.

Because the Zakupomat rule is simple - the cost of running it will never force ads or fees. AI that costs once per name, and 18 cents in total for the whole base, fits within that rule. An AI chat that costs money with every question doesn't. And that's why the "what's missing" list now says "AI chat", not "AI".

The whole feature - from "Shall we do categories with Jev?" to the second TestFlight build with the ordering screen - was built in one Saturday. But the decision on how to do it took a month to mature.


What this means for your team: AI in a product doesn't have to be a chat. It often works best where you can't see it - as a cheap classifier in the background, with a base that learns once, a threshold you change at no cost, and an off switch in case the bill grows. At ANSLAN we help teams find places like that and roll them out responsibly - with privacy and cost worked out before launch.

Zakupomat is a free app for a family's shared shopping, with no ads: zakupomat.app. Categories arrived in version 1.10.0 🙂