Skip to content

Currently: deploying AI monitoring systems at Steady Control

Daniil Dushin

Allergen Labels

Marks the 14 EU allergens on every dish in four languages, and prints nothing until a chef has confirmed it.

Role
Solo project — design, code, tests
Year
2026
Category
AI · Web
Stack
Python 3.12, FastAPI, Jinja2, SQLite
Allergen Labels cover — the 14 EU allergens, three of them marked

The problem

EU food law (Regulation 1169/2011, Article 9(1)(c) and Annex II) requires 14 groups of allergens to be declared, and Article 44 makes this mandatory for food served without packaging, which covers restaurants and hotel kitchens. How it is shown is left to each country.

The hard part is not the list. Kitchens change recipes all the time: a new supplier's stock cube, cashews instead of pine nuts in the pesto, butter in a sauce that used oil. The allergen numbers on the menu are written once and go stale. In Antalya the same menu is read by tourists in several languages, and a guest with a nut allergy has to trust a number next to a dish name.

The solution

  • The chef types the ingredients the way the kitchen writes them, in English, Russian, Turkish or German. A curated dictionary of 2,429 phrases finds the allergens and shows, for every hit, the word it came from.
  • It handles plurals, Russian case endings ("сметаной"), Turkish suffixes ("fındığı", "peynirli") and German compounds ("Haselnusskrokant"), with or without diacritics.
  • Composite products have typical allergens: "soy sauce" is soy and possibly gluten, "pesto" is possibly milk and nuts. Writing pesto (basil, cashews, parmesan) replaces the guess with the real parts.
  • Look-alikes that Annex II does not cover are explained, not flagged: coconut, pine nuts, nutmeg, buckwheat, glucose syrup. "Lactose-free milk" is still milk.
  • Unknown or ambiguous words ("fıstık" is peanut or pistachio, "масло" is butter or oil) are marked for a person. With an API key, Claude suggests groups with a short reason, marked "needs confirmation"; without one, they are simply flagged.
  • Nothing is published until a person confirms the dish with their name. Every confirmation is logged; changing the ingredients resets it; a page opened before someone else changed the recipe cannot confirm it.
  • Printable menu in four languages with allergen numbers and the legend, plus a JSON export.

Screenshots

Dish list with sixteen sample dishes, allergen numbers, and the dishes waiting for confirmation at the top
The dish list in demo mode. Dishes that need a person come first.
Analysis of a chicken satay whose sauce is listed in brackets, with possible allergens, a coconut-milk note and the confirmation form
A dish whose recipe changed after it was confirmed. Every hit shows the word it came from.
Printable German menu with allergen numbers next to each dish and the legend of the 14 allergens
The printed menu in German. Only confirmed dishes are on it.

Tech stack

  • Python 3.12
  • FastAPI
  • Jinja2
  • SQLite
  • Anthropic API
  • pytest
  • Docker

The dictionary does the main work and works offline. The LLM only sees the ingredients the dictionary could not settle; its output is a JSON schema whose allergen field is an enum of the 14 group ids, and its answer only pre-ticks boxes in the confirmation form. SQLite triggers make the confirmation log append-only. The web UI is plain HTML forms, no JavaScript needed.

Code highlight

Every dictionary phrase becomes a regex over a folded text (lower case, no diacritics, Turkish "İ/ı" unified). Each language gets its own word forms:

python
def _word(word: str, language: Language, last: bool, exact: bool) -> str:
    escaped = re.escape(word)
    if exact:
        return escaped
    if language is Language.EN:
        if not last:
            return escaped
        if len(word) > 2 and word.endswith("y") and word[-2] not in _VOWELS:
            return re.escape(word[:-1]) + "(?:y|ies)"
        return escaped + "(?:s|es)?"
    if language is Language.DE:
        return escaped + _DE_ENDINGS
    if language is Language.RU:
        return escaped + "[а-я]{0,6}"
    # Turkish
    stem = escaped
    vowels = sum(ch in _VOWELS for ch in word)
    if word[-1] in _TR_SOFTEN and vowels >= 2:
        stem = f"(?:{escaped}|{re.escape(word[:-1] + _TR_SOFTEN[word[-1]])})"
    suffix = "[a-z]{0,10}" if len(word) >= 4 else _TR_SHORT_SUFFIXES
    return stem + (f"(?P<sfx>{suffix})" if last else suffix)

The matcher then keeps the longest matches that do not overlap, so "peanut butter" wins over "butter" and "Buchweizen" over "Weizen". A Turkish suffix starting with "-sız" ("without") is reported as a free-from word and never counted as the allergen.

Bug I found and fixed

German adjectives were reported as unknown words

What broke

The first run on my labelled sample found every expected allergen, but flagged 42 of 116 lines for a person. "gehackte Walnüsse" and "geriebener Parmesan" were flagged although "gehackt" and "gerieben" are in the dictionary; even "frische Kräuter" was. Nothing was missed, but a tool that cries wolf gets ignored.

Root cause

German words are also matched inside compound words, so their pattern has no word boundaries, and I had left out the endings: "gehackt" covered only part of "gehackte". A word that is only partly covered by a known word is reported as unknown on purpose (otherwise "Salat" would make "Waldorfsalat" look harmless). After adding the endings, "geriebener" still failed: in (?:e|en|er…) the regex takes the first alternative that works, and with no boundary after it, "e" is always enough.

The fix

Compound patterns now take the endings, ordered longest first. Flagged lines on the sample went from 42 to 40, still with 104 of 104 allergens found, and a regression test keeps the adjectives known.

Before
_DE_ENDINGS = "(?:e|en|er|ern|n|s|es)?"

    if compound:
        return re.escape(words[0])
After
_DE_ENDINGS = "(?:ern|en|er|es|e|n|s)?"

    if compound:
        return re.escape(words[0]) + _DE_ENDINGS

Result

  • 220 automated tests: each of the 14 groups with a word in each of the four languages, morphology, false friends, every Annex II exclusion, the confirmation rules, and the Claude client against a fake HTTP server.
  • On a sample of 116 ingredient lines in four languages that I labelled myself: 104 of 104 allergens pre-selected for the chef, 0 silent misses, 40 lines flagged for a person, mostly because of words like "tinned" or "Bio" that the dictionary does not know.
  • Demo mode works offline: 16 sample dishes, including menemen, mercimek çorbası and baklava, with names in four languages.