Munchable 菜单模式:为什么餐厅菜品永远不会显示绿色
A dish on a restaurant menu is never allowed to go green in our app
Munchable 为餐厅场景推出菜单模式:拍照菜单、框选菜品,应用读取菜品描述并对照用户的过敏与饮食条件。该模式在结构上禁止显示绿色结论,引擎判定通过的菜品只标注"Nothing flagged",且不读取菜单自带的过敏原字母代码。菜品以伪产品形式传入端侧引擎,不携带"配料完整"状态,因此置信度无法达到已验证级别;菜单照片在读取返回后立即从内存清除,不留历史记录或缓存。
A barcode works in a supermarket. In a restaurant there is no barcode, there is a menu, and the person who most needs to know what is in the food is sitting there with a laminated card and a waiter who is busy.
So Munchable has a menu mode: photograph the menu, drag a box around a dish, and the app reads what that dish declares and checks it against your conditions and allergies. Most of the engineering in it is not the reading. It is the list of things the app is not allowed to do with what it read.
Rule one: there is no path to green
A product scanned off a shelf can come back "Good fit", because its ingredient list is the whole recipe by law. A dish on a menu cannot, because a menu is marketing copy with a price next to it. "Slow roasted tomatoes, onion and double cream" is a sentence, not a declaration, and the onion in it is a fact while the stock the chef used is not.
So menu mode has a cap, and the cap is three lines:
/** MENU CAP: menu mode can never show green. Applied for display only. */
export function capForMenu(v: VerdictValue): VerdictValue {
return v === 'good' ? 'caution' : v;
}
The wording follows the same rule. A dish the engine cleared is labelled "Nothing flagged", never "Good fit":
/** Label for a dish or condition the engine cleared: honest, never "Good fit". */
export const MENU_NOTHING_FLAGGED = 'Nothing flagged';
And the heading over the whole menu carries the qualifier in the sentence rather than in a footnote, because the heading is the part people actually read:
No printed ingredient is flagged for you
"Printed" is doing all the work in that line. The alternative draft, "nothing is flagged for you", is a claim about the dish. This one is a claim about the menu, which is the only thing we looked at.
Rule two: the cap is structural as well as cosmetic
A display-only cap is one if away from being bypassed by a future screen that forgets it exists. So the data shape refuses green too. A dish is handed to the engine as a pseudo product:
/**
* A dish as a pseudo product for the on device engine. It never carries the
* "ingredients completed" state, so confidence can never reach verified, and
* unknown printed words gate confidence down exactly like a label capture.
*/
export function dishToProduct(dish: MenuDish, index: number): Product {
return {
barcode: `menu:${index}`,
ingredientsText: dish.description || dish.ingredients.join(', '),
ingredientsTags: dish.mapped.ingredientsTags,
unknownIngredientsN: dish.mapped.unknownIngredientsN,
dataSource: 'menu',
};
}
No "ingredients completed" flag means the top confidence badge is unreachable by construction, not by convention. The same engine that runs on a barcode runs on the dish, with no menu-specific rules anywhere inside it, which is how a dish and a packet of the same thing cannot give contradictory answers.
That barcode field is also a tell. menu:0 is an index, not an identity. The next photo of the same menu produces menu:0 for whatever dish happens to be first in the box, which is deliberate: there is no stable key for a dish, so there is nothing for a cache or a history row to be keyed on.
Rule three: we do not read the restaurant's own allergen codes
This is the one that feels wrong until you think about it for a minute.
Menus print allergen letters. There is a column of G and M and N down the right hand side, and the data is right there, pre-structured, free. Reading it would make the feature look much cleverer.
We do not read it:
// Menus print their own allergen letters and every restaurant means something
// different by them, so Munchable does not read them. Saying "nothing names
// milk" over a menu that prints codes would be a claim we did not check.
const printsCodes = fits.some((f) => f.dish.allergens.length > 0);
The letters are not a standard. One kitchen's M is milk, another's is mustard, a third prints a legend in six point type at the bottom of the page. On a declaration where being wrong matters, a guess dressed as a parse is worse than an honest gap, and an honest gap is cheap to say out loud. So the variable is used for one thing only: changing what the app admits to.
When the menu prints codes:
This menu prints its own allergen codes, which Munchable does not read. Check them for peanuts and ask the staff.
When it does not:
Nothing printed on this menu names peanuts. Menus rarely list everything, so ask the staff.
Both of those lines are on screen in the clean state. The allergy line is present whether or not anything was found, because a menu that prints nothing about your allergen is not a menu without it, and the sentence has to say which of those two it is. The dish-level rows use the same wording module as the shelf-scan result screen, so the two surfaces cannot drift apart on the subject where they have to agree.
Rule four: nothing about a menu is kept
No history row, no cache entry, no photo.
The boxed region is shown back to you while the read happens, so a bad box is obvious immediately, and that preview is a cache file URI rather than the image payload. The payload goes the moment the read returns, in both branches:
void ocrMenu([{ base64: shot.base64, kind: 'menu' }]).then((res) => {
// The photo has done its job either way: drop it from memory.
clearPendingMenuShot();
if (cancelled) return;
...
});
Note that the clear happens before the cancellation check, not after. If you close the screen while the read is in flight, the early return must not be the thing that skips the cleanup, and a cancelled guard placed one line higher is exactly how a transient photo stops being transient.
This is the same posture as the rest of the app: the food log has no server behind it at all, and label photos are read and then discarded rather than stored, which we state publicly on munchable.app/licenses.
The box itself
Drawing the box is the only part of this that is ordinary. The user drags on the live camera preview, the app takes the still the moment they let go, and the drawn rectangle is mapped into the photo through one uniform scale and a centring offset, because the preview is a centre-cropped cover fit of the sensor frame on both platforms.
All of that geometry is pure functions in one module, so it can be reasoned about and unit tested without a device, with two constants that exist only because of hands: a minimum drag size, so a tap is never read as a selection, and a few points of padding around the box, so a letter sitting on the edge survives the hand drift between the drag and the shutter. We wrote about the padding when we worked it out for label capture.
Where to look
Menu mode needs an account and a camera, because every read costs a model call, so the honest thing to link is what you can see without either.
The engine that judges a dish is the same one that renders our public ingredient pages, server side: is garlic low FODMAP and does garlic cause reflux are the two questions a garlic bread on a menu raises, answered by the code path a dish goes through. A few hundred more are indexed at munchable.app/answers, and each condition's rule set has a public guide at munchable.app/conditions.
The app runs in a browser at app.munchable.app, where the whole onboarding works before you create an account.
The summary of the four rules is one sentence that sits at the top of every menu result, which is also the sentence the whole feature is designed around: a clean result here is never a green light.
来源:Google AI:DEV 作者专属(RSS) · dev.to