I run OneFindMe, an AI product-search front end for a
large marketplace. The last thing I shipped was a feature that sounds trivial and
isn't: type a need and a hard budget — "useful things for a big dog, 80 total" —
and get back a real basket of real products that never goes a cent over.
The word doing the work is real. The model is not allowed to invent a single
product, price, rating or shipping figure. Everything with a number attached comes
from the marketplace API. The model only gets to do the one thing it's actually
good at: understand what a human meant. Drawing that line is where all the
interesting failures lived.
Here's what broke.
What the AI is actually allowed to do
When someone types "useful things for a big dog, 80," the model does not return
products. It returns a plan:
{
"domain": ["dog", "pet", "כלב"],
"roles": [
{ "he": "Chew toy", "kw": "dog chew toy" },
{ "he": "Leash", "kw": "large dog leash" },
{ "he": "Brush", "kw": "pet hair brush" },
{ "he": "Bowl", "kw": "dog food bowl" }
]
}
That's it. A set of complementary roles that together serve the need, each with
a search keyword. No prices, no products, no ratings — the model never sees a
catalogue. Every role keyword then goes to the real search endpoint, in parallel,
and comes back with real listings: price, image, rating, order count, affiliate
link. The AI understood the intent; the marketplace supplied the facts.
This split is the whole design. The moment you let a language model emit a price
or a product name, you've built a very confident fiction generator. Keep it on the
intent side of the wall and it's genuinely useful.
The data that simply does not exist: shipping
The feature has a switch: does the budget include shipping? Honoring it turned
out to be impossible in the obvious way, because the affiliate API does not
return a shipping cost. It returns an item price and a delivery time in days —
never a freight figure. There is no endpoint that gives you "this item ships to
that country for X."
I could have had the model estimate shipping. That's exactly the invention I'd
banned. So the honest version:
- "Include shipping" → filter the search to free-shipping items only. Now shipping is a verified zero, not a guess, and the budget math stays true.
- "Products only" → ignore shipping entirely and say so.
Currency was a smaller version of the same lesson: the marketplace converts prices
server-side when you pass a target currency, so there is no live FX call to
reconcile per item — you just work in one currency the whole way through.
Coupons exist in the payload but change without notice, so they're surfaced as a
caveat, never subtracted from the total. If a number can't be trusted, it doesn't
get to move the budget.
Choosing items without going over: yes, it's Knapsack
Once each role has a pool of real candidates, picking a combination that maximizes
value without exceeding the budget is the 0/1 Knapsack
problem wearing a shopping hat.
I didn't reach for a full dynamic-programming solution, for three reasons: the
item count is small (a handful of roles, ~20 candidates each), the whole thing
runs inside a request budget of a few seconds, and there's a constraint textbook
knapsack doesn't have — diversity. Two chew toys is not a good dog basket even
if the numbers are optimal.
So it's a greedy build with a fill pass:
// 1. base: cheapest viable item per role, so every role is covered
for (const role of rolesByCheapest) {
const pick = role.candidates.find(c => spend + c.price <= budget);
if (pick) { basket.push(pick); spend += pick.price; }
}
// 2. fill: add new-role items (variety first), then upgrade to pricier/better
// picks, until only a few units of budget remain
while (budget - spend > SLACK && basket.length < MAX) {
const add = bestAffordableNewRole(spend); // prefer an unused role
const up = bestUpgradeThatUsesBudget(spend); // else spend up on a better item
if (!apply(add, up)) break;
}
The base pass guarantees coverage. The fill pass is what makes the basket actually
feel like the budget you asked for — which brings me to the bug that embarrassed
me most.
The basket that spent 36 of a 200 budget
Early on, a "cosmetics basket, 200" came back at 36. Technically valid — every
item real, under budget, nothing invented. Practically useless. A customer asking
for a 200 basket and getting 36 worth of stuff feels short-changed, not thrifty.
The cause was the value function. "Quality first" scored items by rating and
sales, which has no opinion about using the budget. It happily picked one cheap,
well-rated item per role and stopped. The fix was two-part: scale the number of
roles with the budget (a 200 cosmetics basket wants 6–9 item types, not 3), and
add the fill loop above, which explicitly targets a near-full budget. Cosmetics at
200 now lands at ~199. Never over — that constraint is absolute — but close enough
that the number you typed is the number you get.
The bug that made it look like a scam
The one that actually scared me: a nail-polish basket returned a women's coat
for 80. Nothing about a coat belongs in a nail order.
Root cause was a relevance shortcut. Each role carried a "must contain" keyword to
filter noise, and the role top coat had contributed coat. A listing titled
"Women Suede Coat" matched coat and sailed through. A generic word from one role
had opened the door to a completely different category.
The fix was to stop filtering per role and filter per basket. The planner now
returns a domain — a few need-specific stems, in every language the title might
be in — and every item, whatever its role, must contain one of them:
const domain = ["nail", "polish", "manicure", "ציפורנ"]; // for "nail polish"
const relevant = p =>
domain.some(stem => (p.title + " " + p.titleLocal).toLowerCase().includes(stem));
A coat contains none of nail / polish / manicure, so it's gone — regardless of
which role's keyword it happened to match. Precision beat recall here on purpose:
in a basket, one off-topic item reads as "this thing is broken," and I'd rather
drop a borderline product than ship the coat.
Speed beat correctness. Again.
The first working version took 10–16 seconds cold: one planning call to the model,
then N marketplace searches. Users don't wait 15 seconds for a basket; they leave.
Two things fixed it. The searches were already cached, so the second time anyone
builds a similar basket the role searches are warm. And the plan — the roles for
a given need and budget band — is cacheable too, and priority-independent, so I
cache it and skip the model entirely on a repeat. A brand-new query is still
~10s (N cold marketplace round-trips are the floor), but a repeat is 0.4s. As
the cache warms across users, more baskets land in the fast path. Same lesson I
keep relearning: a correct answer that arrives too late is a wrong answer.
What happens when a price changes after you build the basket
It will. Prices and stock on a live marketplace move by the hour. The basket you
show is a snapshot, and pretending otherwise is the same sin as inventing a
shipping figure. So the total is computed server-side from the freshest search at
build time, the items carry the marketplace's own "prices may change" caveat, and
the buy links go straight to the live listing where the real, current price is
authoritative. The basket is a starting point that respects your budget, not a
locked quote — and it says so.
The pattern underneath all of it
Every one of these fixes is the same move: let the model interpret, never let it
assert. It's brilliant at turning "stuff for a big dog, 80" into search terms and
domain anchors. It's a liability the instant it emits a price. Keep the language
work and the truth work on opposite sides of a hard wall, and the failures stop
being "the AI hallucinated" and start being ordinary, fixable engineering — a
missing field, a too-greedy heuristic, a generic keyword that matched the wrong
thing.
The budget basket that came out of it is live on OneFindMe if you want to see the
shape of it. But the interesting part was never the demo. It was the wall.
Top comments (0)