← Writing

From a menu photo to a real menu.

How the AI menu importer turns a photo or PDF into dishes with sizes, prices and categories, and the parts that needed rules instead of prompts.

Sakshyam Baral··5 min readaiproductlaayo

Getting a restaurant onto Laayo used to mean typing its menu by hand: every dish, every price, every size, every category. The menus come as laminated sheets, phone photos of a wall board, or a PDF someone designed years ago. So I built an importer. You drop in photos or a PDF, get a table of dishes you can review, fix what is wrong, and commit.

It started as a page in the old Laravel admin. It now lives in the Next.js operations console, with the commit logic in Go. The shape of the pipeline stayed the same.

The pipeline

  1. A PDF is split into one image per page.
  2. Images go straight to a vision model. My first version ran OCR and sent the text to a language model. Sending the image directly was simpler, one call instead of two services, and the model sees the layout, so it can tell which price sits next to which dish.
  3. Images are batched, two per call by default, and all the batches run in parallel rather than one after another.
  4. The model returns JSON: dish name, price, sizes if there are any, a veg flag and a suggested category.
  5. A person reviews everything in an editable table, then commits.

Extraction runs as a background job. The start request saves the uploads, returns a job id and finishes the HTTP response, then keeps working. Progress goes into the cache, and the page polls it a little more often than once a second: preparing pages, extracting batch three of five, dishes found so far. A large menu shows steady progress instead of a page that seems stuck.

Each step also shows what it used in tokens and generated images.

Sizes are where menus get interesting

A lot of dishes here come in sizes or styles. A momo line might read "Steam 200 · Fry 240 · C 280": steamed, fried or chilli, at three prices.

Early on this was handled badly. The sizes showed as grey text next to a single price box holding the wrong number, and you couldn't edit them. The rules I ended up with:

  • When a dish has sizes, its base price is the cheapest size. Each size is stored as an option whose price is the difference from that base.
  • If the name ends in a bracket and every word in it is one of the dish's sizes, the bracket goes. "Chicken Momo (Steam, Fry, C)" becomes "Chicken Momo", because the options already say it.
  • The option group is called "Size" when every option is a size word (half, full, small, large, a quantity in ml, pieces or plates), and "Type" otherwise. Steam, fry and chilli are types.
  • When a dish has sizes, the main price box is read-only and shows the cheapest size, so nobody can type a base price that disagrees with its options.
  • A dish is flagged "needs a price" if any size is missing a name or a price.
type Size = { name: string; price: number };
 
function toVariation(sizes: Size[]) {
  const priced = sizes.filter((s) => s.name.trim() !== "" && s.price > 0);
  const base = Math.min(...priced.map((s) => s.price));
  return {
    price: base,
    variation: {
      name: priced.every((s) => SIZE_WORD.test(s.name)) ? "Size" : "Type",
      type: "single",
      required: true,
      values: priced.map((s) => ({ label: s.name, optionPrice: s.price - base })),
    },
  };
}
type Size = { name: string; price: number };
 
function toVariation(sizes: Size[]) {
  const priced = sizes.filter((s) => s.name.trim() !== "" && s.price > 0);
  const base = Math.min(...priced.map((s) => s.price));
  return {
    price: base,
    variation: {
      name: priced.every((s) => SIZE_WORD.test(s.name)) ? "Size" : "Type",
      type: "single",
      required: true,
      values: priced.map((s) => ({ label: s.name, optionPrice: s.price - base })),
    },
  };
}

The Go commit applies the same rule on the server. If a client sends sizes with prices and also a base price that contradicts them, the server ignores the base and uses the cheapest size.

Where the model needed fences

Most problems were not about the model reading the menu. They were about what happened around it.

Truncated JSON. The menu organizer, which proposes moving dishes between categories, had an output limit of 3,000 tokens. A big menu produced more moves than fit, the JSON was cut off mid-object, and the user saw "AI returned an unparsable response". The code now reads the finish reason. If the output stopped because of length, it retries once and asks for shorter reasons and fewer moves. It accepts JSON wrapped in code fences. If it still doesn't fit, the message says the menu is too big for one pass, which is the truth, instead of blaming the parser.

Categories. Auto-picking categories sends forty dishes per call, in parallel, in JSON mode, and every category id the model returns is checked against the real category list before it is shown. Picked rows get an outline until someone edits them, so a person can see what the model chose. One prompt line fixed the most visible mistake: use a child category only if it is the same dish. Before that, "Chicken Chilli" landed under "Chicken Sausage Chilly" because the words overlapped.

The shape of the commit. A picked sub-category was being sent alone as the dish's category, so the saved dish had no parent category and didn't show up where people looked for it. The fix was to always send the parent and the child together. No prompt would have found that one.

Descriptions and photos are separate tools that run over dishes already in the system. Generating saves at once. Hand edits are tracked per card with an "unsaved" state, and changing a filter, changing page or refreshing asks before throwing edits away. When everything is saved, the button says "All saved" instead of sitting there grey, because a disabled grey button reads as broken.

Nothing the model produces goes straight to customers. Every dish passes through the review table, where the model's mistakes are quick to fix, and a person presses commit.

Get new posts by email

One email when something new goes up. No tracking pixels, unsubscribe in one click.