Перейти к содержимому

Сейчас: внедряю системы ИИ-мониторинга в Steady Control

Daniil Dushin

Allergen Labels

Отмечает 14 аллергенов ЕС в каждом блюде на четырёх языках и ничего не печатает, пока шеф не подтвердит.

Роль
Личный проект — проектирование, код, тесты
Год
2026
Категория
ИИ · Веб
Стек
Python 3.12, FastAPI, Jinja2, SQLite
Обложка Allergen Labels — 14 аллергенов ЕС, три из них отмечены

Проблема

Закон ЕС о пищевой информации (Регламент 1169/2011, статья 9(1)(c) и Приложение II) требует указывать 14 групп аллергенов, а статья 44 делает это обязательным для еды без упаковки, то есть для ресторанов и кухонь отелей. Как именно показывать эту информацию, решает каждая страна.

Сложность не в самом списке. Кухня постоянно меняет рецепты: бульонный кубик от нового поставщика, кешью вместо кедровых орехов в песто, сливочное масло в соусе, где раньше было растительное. Номера аллергенов в меню пишут один раз, и они устаревают. В Анталье одно и то же меню читают туристы на разных языках, и гостю с аллергией на орехи остаётся доверять цифре рядом с названием блюда.

Решение

  • Шеф вводит ингредиенты так, как их пишут на кухне: на английском, русском, турецком или немецком. Курируемый словарь из 2429 фраз находит аллергены и для каждой находки показывает слово, из которого она взялась.
  • Он понимает множественное число, русские падежи («сметаной»), турецкие суффиксы («fındığı», «peynirli») и немецкие составные слова («Haselnusskrokant»), с диакритикой и без неё.
  • У составных продуктов есть типичные аллергены: «соевый соус» — это соя и возможно глютен, «песто» — возможно молоко и орехи. Если написать pesto (basil, cashews, parmesan), догадку заменяют настоящие составляющие.
  • То, что похоже на аллерген, но не входит в Приложение II, объясняется, а не отмечается: кокос, кедровые орехи, мускатный орех, гречка, глюкозный сироп. «Безлактозное молоко» остаётся молоком.
  • Незнакомые и неоднозначные слова («fıstık» — арахис или фисташки, «масло» — сливочное или растительное) отмечаются для человека. С API-ключом Claude предлагает группы с коротким объяснением и пометкой «нужно подтверждение»; без ключа такие слова просто отмечаются.
  • Ничего не публикуется, пока человек не подтвердит блюдо своим именем. Каждое подтверждение записывается в журнал; изменение ингредиентов его сбрасывает; страница, открытая до того, как кто-то другой поменял рецепт, подтвердить его не может.
  • Печатное меню на четырёх языках с номерами аллергенов и легендой, плюс экспорт в JSON.

Скриншоты

Список из шестнадцати демо-блюд с номерами аллергенов; блюда, ждущие подтверждения, наверху
Список блюд в демо-режиме. Первыми идут блюда, которым нужен человек.
Разбор куриного сатая с соусом в скобках, возможными аллергенами, пометкой про кокосовое молоко и формой подтверждения
Блюдо, рецепт которого изменили после подтверждения. У каждой находки видно слово, из которого она взялась.
Печатное меню на немецком с номерами аллергенов у каждого блюда и легендой из 14 аллергенов
Печатное меню на немецком. В нём только подтверждённые блюда.

Технологии

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

Основную работу делает словарь, и он работает без интернета. LLM видит только те ингредиенты, с которыми словарь не справился; её ответ — JSON по схеме, где поле аллергенов ограничено перечнем 14 идентификаторов групп, и он лишь заранее ставит галочки в форме подтверждения. Триггеры SQLite делают журнал подтверждений неизменяемым: записи можно только добавлять. Веб-интерфейс — обычные HTML-формы, JavaScript не нужен.

Фрагмент кода

Каждая фраза словаря превращается в регулярное выражение для «свёрнутого» текста (нижний регистр, без диакритики, турецкие «İ/ı» приведены к одному виду). У каждого языка свои формы слова:

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)

Затем матчер оставляет самые длинные непересекающиеся совпадения, поэтому «peanut butter» побеждает «butter», а «Buchweizen» (гречка) — «Weizen» (пшеницу). Турецкий суффикс «-sız» («без») считается словом «без аллергена» и никогда не засчитывается как сам аллерген.

Баг, который я нашёл и исправил

Немецкие прилагательные считались незнакомыми словами

Что сломалось

Первый прогон на моей размеченной выборке нашёл все ожидаемые аллергены, но отправил на проверку человеку 42 строки из 116. «gehackte Walnüsse» и «geriebener Parmesan» попадали на проверку, хотя «gehackt» и «gerieben» есть в словаре; даже «frische Kräuter» попадала. Ничего не было пропущено, но инструмент, который зря поднимает тревогу, перестают слушать.

Причина

Немецкие слова ищутся и внутри составных слов, поэтому у их шаблона нет границ слова, а окончания я не добавил: «gehackt» покрывал только часть «gehackte». Слово, частично покрытое только известным словом, специально считается незнакомым (иначе «Salat» сделал бы безобидным «Waldorfsalat»). После добавления окончаний «geriebener» всё равно не проходил: в (?:e|en|er…) регулярное выражение берёт первый подходящий вариант, а без границы после него всегда хватает «e».

Исправление

Шаблоны составных слов теперь принимают окончания, самые длинные — первыми. Строк на проверку стало 40 вместо 42, найдено по-прежнему 104 аллергена из 104, а регрессионный тест не даёт прилагательным снова стать незнакомыми.

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

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

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

Результат

  • 220 автоматических тестов: каждая из 14 групп со словом на каждом из четырёх языков, морфология, «ложные друзья», все исключения Приложения II, правила подтверждения и клиент Claude против поддельного HTTP-сервера.
  • На выборке из 116 строк ингредиентов на четырёх языках, которую я разметил сам: 104 из 104 аллергенов заранее отмечены для шефа, 0 тихих пропусков, 40 строк отправлены на проверку человеку — в основном из-за слов вроде «tinned» или «Bio», которых нет в словаре.
  • Демо-режим работает без интернета: 16 демо-блюд, среди них менемен, мерджимек-чорбасы и пахлава, с названиями на четырёх языках.