Villa Search 2

Developers

Search API

One call turns what a shopper types, in English or Thai, into a page of Villa Market products: the best match first, then sections, with stock and promotions. This page is for Villa's web team, to put it into villamarket.com.

Base URLSEARCH_API_BASE
FormatJSON over HTTPS
LanguagesEnglish and Thai, as typed
AccessVilla's sites (CORS) or an API key
A service in test. The default engine is routed: it re-ranks the shop's own search with the model (rerank), and hands a search to the newer engine, v4, which reads occasions, needs and dishes, when the search names a dish or a group, when the shop's search finds nothing, or when the re-ranked page has no good match. /api/meta names the default; ask for rerank or v4 by name to pin one. Stock, prices and promotions are a read-only snapshot of Villa Market's data: /api/meta says when it was taken.

Quick start

Send the search as typed. Ask for the simple view: the storefront's own cards and sections.

const res = await fetch("SEARCH_API_BASE/api/search", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ q: "turkey", view: "simple" }),
});
if (!res.ok) throw new Error(`search: ${res.status}`);
const result = await res.json();
console.log(result.page[0].name); // "Oven Roasted Turkey"

The answer (a real one, trimmed to two products):

{
  "query": "turkey",
  "engine": "rerank",
  "page": [
    {
      "cprcode": "257624",
      "name": "Oven Roasted Turkey",
      "name_th": "เนื้อไก่งวงบดอัดก้อนปรุงรส",
      "brand": "SPAM",
      "size": "340g",
      "price": 269,
      "list_price": 299,
      "promo": null,
      "stock": 74,
      "image": "https://d1vl5j0v241n75.cloudfront.net/0257624-1",
      "country": "US",
      "shelf": "Grocery › Cans & Packets › Meals & Meats",
      "group": "Cans & Packets › Meals & Meats",
      "tier": "STRONG",
      "band": 0
    },
    {
      "cprcode": "136873",
      "name": "Roasted Carved Turkey",
      "brand": "Hungry-Man",
      "size": "454g",
      "price": 310,
      "list_price": null,
      "promo": null,
      "stock": 9,
      "image": "https://d1vl5j0v241n75.cloudfront.net/0136873",
      "country": "US",
      "shelf": "Frozen Food › Ready Meals › Chicken",
      "group": "Ready Meals › Chicken",
      "tier": "STRONG",
      "band": 0
    }
  ],
  "groups": [
    {
      "key": "Cans & Packets › Meals & Meats",
      "count": 1
    },
    {
      "key": "Ready Meals › Chicken",
      "count": 1
    },
    {
      "key": "Pies & Sausage Rolls › Pies",
      "count": 2
    }
  ],
  "understood": {
    "kind": "product",
    "label": "turkey",
    "food": true,
    "country": null,
    "intent": [],
    "alternative": {
      "kind": "origin",
      "q": "products from turkey",
      "country_en": "Turkey",
      "country_th": "ตุรกี"
    },
    "did_you_mean": null,
    "suggestions": [
      "products from turkey",
      "Breadcrumbs & Stuffing"
    ]
  },
  "jev": {
    "requests": 2,
    "cached": 2,
    "input_tokens": 7863,
    "live_requests": 0,
    "live_input_tokens": 0,
    "limit": null
  },
  "timing": {
    "total_ms": 51
  }
}

Show page in the order it comes, in the sections of groups, under the line understood describes. The integration guide covers each part, or the widget renders it all in one line.

The response

FieldWhat it is
queryThe search, as read.
engineThe engine that answered: rerank, v4 or jev. A routed search names the engine it went to.
routerouted only: { engine, why }. engine is rerank or v4; why is null for rerank, or one of "dish", "group", "origin" (an origin stated outright: "products from Japan"), "shop-empty", "shop-error", "attribute-missing" (the shop's list has none with the attribute asked for: "fresh prawn"), "rerank-no-good" and "supplies-only" (a search that is nothing but an occasion, such as "birthday" or วันเกิด, whose re-ranked first screen is 80% or more supplies, answered by v4 when it builds a page for that occasion: "birthday"; otherwise rerank's page stays, with "supplies-only; v4 no occasion page"), with "; v4 stopped" added when a limit kept v4 from finishing, so rerank's page stays. A quick answer carries the route too, decided without grading, so it never says "rerank-no-good" or "supplies-only"; the full answer can.
pageThe products, best first, usually up to 30. Each is a product card (below).
groupsThe page's sections, in order: { key, count }. A card's group is one of these keys.
group_thThai names for sections that are smart groups (v4): { "BBQ": "บาร์บีคิว" }.
understoodHow the search was read: kind (product, intent or origin), label, intent ([{ en, th }]), did_you_mean, alternative (the other reading of an ambiguous word: "products from turkey") and suggestions for a thin or empty page.
noticePresent only when the model was not asked (a limit, or the model unavailable): "jev_unavailable" (the shop's own order) or "jev_unavailable_words" (word matches). The page is still usable; say so in one line.
jevWhat the search cost: requests, cached, input_tokens, live_requests, live_input_tokens, and limit when one stopped it.
timing{ total_ms } on the server.
treev4 only: the path its decision tree took, node by node.
today, today_metaWith compare: villamarket.com's own order for the same search, and its count and time.

A product card

FieldWhat it is
cprcodeVilla's product code (a string): the key for the product page and the cart.
name, name_thThe English name, and the Thai name when there is one.
brand, size, countryBrand, pack size ("340g"), and country of origin (ISO code; TH is local).
price, list_price, promoThe shelf price in baht; the price before a promotion, when there is one; and the promotion's own label.
stockUnits at the branch. 0 or less is out of stock: keep the card, labelled, after the in-stock ones.
imageThe product photo's URL, or null.
shelf, groupVilla's category path ("Grocery › Cans & Packets › Meals & Meats"), and the page section it belongs to.
tierThe model's grade against the search: EXACT, STRONG, RELATED or NOT; null when not graded.
band0 what was asked for, 1 a substitute, 2 not what was asked. Show band 0 first.
match"brand" when only the brand matches (Turkey Hill syrup for "turkey"), "pets" for pet products; these come last.

Other fields (reading, pipeline, counts, context_sent and more) are the demo's diagnostics. They are not part of the contract and may change.

GET /api/meta

The service's settings, for a status line or a health check.

FieldWhat it is
default_engineThe engine a search gets without engine: routed, rerank or v4.
jev_budget, jev_tokens_today, jev_dayThe site's model budget for the day, what is used of it, and the day (UTC).
costThe public prices behind pricing: the model's price per million input tokens, AWS per search, and the measured tokens of a product and an occasion search.
snapshotWhen the product data was read from Villa Market.
branchThe branch whose stock and prices the answers use.

Errors

Errors are JSON: { "error": "…" }, a sentence safe to log (never to show as it is: show your own words).

StatusWhenWhat to do
400The body is not JSON, q is empty, or context is over 8 KB.Fix the request.
401An X-Api-Key that is unknown or revoked.Check the key; no key means the visitor's limits.
413The body is over 20 KB.Send less context.
429Too many searches this minute. Retry-After: 60.Show the storefront's own search, and try again after a minute.
503The model is unavailable for a search that has no plain answer, the service is starting, or an API key could not be checked.Fall back to the storefront's own search.
500A fault on our side.Fall back; it is logged.

Pricing and limits

A search costs what the model reads for it, plus a small share of the servers. Nothing runs between searches: there is no search cluster or cache server to pay for.

cost of a search ≈ live model input tokens × $0.042 / 1,000,000 + $0.00003 (AWS)
SearchLive model tokensCost
A repeat within 30 days0≈ 0.003¢
A product searchabout 6,200≈ 0.03¢
An occasion, a need or a recipeabout 13,000≈ 0.06¢
1,000 searches (the test set's mix, none cached)about 7.9 million≈ $0.36
  • Repeats cost no model calls for 30 days. The model's answers are kept, so a popular search pays the model once a month; each repeat still costs the servers' share, ≈ 0.003¢. The table counts every search as new, so the real average is lower.
  • Some answers need no model calls at all: the quick phase, and the shop's own order when a limit is reached (with a notice).
  • Every search is capped: a simple search stops asking the model at 3 requests or 20,000 tokens (about 0.09¢), whichever comes first.
  • Prices: the model input price ($42 per billion input tokens; no output price is listed); AWS at public prices, approximate. /api/meta carries them as cost, with jev_tokens_today.

Limits

The model is asked only when an answer is not already known. The limits below count only live model requests.

LimitValueWhen it is reached
Searches a minute, per visitor30 (the quick phase has its own 30)429 for the rest of the minute.
Searches with the model a day, per visitor100Answers without the model, with a notice.
Model use per search3 requests, 20,000 input tokens (simple)The search answers with what it has.
Model use a day, the whole sitejev_budget in /api/metaEvery search answers without the model, with a notice, until the day ends (00:00 UTC, 07:00 in Bangkok).

A request with an API key has its client's own rate and daily model tokens instead of the visitor's: see access.

Guide

Integrate villamarket.com

Where to call it

  • The search results page first. Call it when a shopper submits a search, from the page or from your server (with an API key).
  • Type-ahead later. The quick phase answers in milliseconds with no model calls, and suits suggestions as the shopper types; the full search runs when they submit.
  • Keep the storefront's own search as the fallback. On a 429, a 5xx or a timeout (5 seconds is plenty), show today's search, never an empty page.

Render the page

  1. The understood line. understood.label, with the count in stock. If there is did_you_mean or an alternative, one link each: a click is a new search.
  2. Filters. Chips over the products returned: in stock, on promotion (promo or list_price), local (country TH). Hide a chip that would not change the page.
  3. The best match. One larger card: the first in-stock card of the best band among the first three. Never an out-of-stock card or a substitute.
  4. Sections. Group page by group, in the order of groups. A section of one product can join a "Top matches" row. Keep the page's order inside each.
  5. Each card. Photo, name, brand and size, price; a struck-through list_price with a promotion; "Out of stock" greyed but readable.
  6. A notice. When notice is present, one quiet line: "Showing the shop's usual results."

This is the demo's Simple view. The widget is that view in one element, and its script (villa-search.js, not minified) is the reference: layout() makes the sections, renderSmart() the page.

English and Thai

A shopper can search in either language on either page. Show name_th on a Thai page when it is there, else name. Section names are Villa's English category names: translate them with group_th first, then your own category table.

Caching

Answers carry Cache-Control: no-store, because stock and the day's budget change. A server may keep an answer a minute per search. Repeats are cheap anyway: the service keeps the model's answers for 30 days.

Analytics worth sending

EventWithWhy
searchquery, engine, products shown, notice, timeVolume, speed, and how often the model was not asked.
search_clickcprcode, position, section, bandWhether the first result is the one people take.
search_add_to_cartcprcode, positionThe measure that matters.
search_emptyqueryThe searches to fix next.
search_refine"Did you mean", an alternative, a suggestion or a filterWhere the first reading missed.

Guide

The <villa-search> widget

The whole results page in one element: the understood line, filters, the best match and sections, in English or Thai, on a phone or a desktop. It is the demo's Simple view, in a shadow root, so your CSS and its CSS never meet.

<script src="SEARCH_API_BASE/villa-search.js" defer></script>
<villa-search q="turkey" lang="th"></villa-search>

It calls the API from the shopper's browser, so the page's site must be on the CORS list. It never takes an API key.

Attributes

AttributeWhat it does
qThe search. Changing it searches again; so does el.search("…").
langen (default) or th.
engineLeave it out for the default; routed, rerank or v4 to pin one.
product-urlWhere a product card leads, with {cprcode} in it: /product/{cprcode}.
apiThe API's address; by default, where the script came from.

Events

EventDetail
villa-search:resultsThe API's answer, as above.
villa-search:select{ cprcode, name, product } when a shopper opens a product. Call preventDefault() to stop product-url and route it yourself.
villa-search:query{ q } when a shopper takes "Did you mean" or a suggestion: update your search box.
villa-search:error{ status, message }. The widget shows "try again"; show your own search if you prefer.
villa-search:refined-ready{ q }. On a slow search the widget first shows quick results; if the shopper has scrolled or tapped by the time the full answer comes, it does not swap under their finger but offers "Refined results ready · Show" at the top of its results. Offer it in your own header too if you like, and call el.showRefined(); villa-search:results follows. Give the element a scroll-margin-top the height of your sticky header.
const results = document.querySelector("villa-search");
results.addEventListener("villa-search:select", (e) => {
  e.preventDefault();
  router.push(`/product/${e.detail.cprcode}`);
});
searchBox.addEventListener("submit", (e) => {
  e.preventDefault();
  results.search(searchBox.q.value);
});

Style it

Set these on the element or any parent. Each has the demo's colour as its default.

--vs-font--vs-ink--vs-muted--vs-card--vs-paper--vs-line--vs-accent--vs-accent-2--vs-promo--vs-notice--vs-radius--vs-shadow
villa-search {
  --vs-font: "Kanit", system-ui, sans-serif;
  --vs-accent: #0b6e4f;
  --vs-promo: #ffcc00;
  --vs-radius: 8px;
}

Tool

Playground

A real search against this service: the widget on the left, the JSON on the right. Each run is one search (two requests: quick, then full).

Rendered

Search above, or pick an example, to see the widget here.

JSON
Search to see the answer.

Production

Access and API keys

From a browser: Villa's sites

Pages on these sites may call the API directly. The browser sends its Origin, and the API answers with Access-Control-Allow-Origin for these only:

  • https://shop.villamarket.com
  • https://dev.villamarket.net
  • https://villamarket.com
  • https://www.villamarket.com

Each shopper counts as a visitor for the limits. Another site, a new test host for instance, is a setting: ask for it.

From a server: an API key

A server that calls on behalf of many shoppers sends a key, and gets its own rate and daily model tokens instead of one visitor's. The site's daily model budget still applies to everyone.

curl -s SEARCH_API_BASE/api/search \
  -H 'Content-Type: application/json' \
  -H "X-Api-Key: $VILLA_SEARCH_API_KEY" \
  -d '{"q":"turkey","view":"simple"}'
  • Keys are issued by hand, one per client, with its searches a minute and model tokens a day. Ask the team that runs this service.
  • A key lives on your server: in its secret store, never in a web page, a repository or a chat. Only a hash of it is kept here.
  • An unknown or revoked key gets 401. A key that leaks is revoked, and a new one issued.