Як я навчив Telegram-бота рахувати інсулін за фото їжі — і де все пішло не за планом

💡 Усі статті, обговорення, новини про AI — в одному місці. Приєднуйтесь до AI спільноти!

Всім привіт! Я Ярослав Сорочан, Backend developer. У цій статті розповім, як і чому я збудував Telegram-бота, що розпізнає їжу по фото, рахує КБЖУ — і, за потреби, дозу інсуліну.

Преамбула, якої ніхто не просив

Цей проєкт почався з одного простого питання: чому людині з діабетом 1-го типу, яка користується шприц-ручкою (без помпи чи автоматизованих систем), все ще доводиться гуглити «скільки вуглеводів у картоплі фрі», потім вручну рахувати порцію і ділити на свій ICR — замість того, щоб просто сфотографувати їжу?

Адже вся ця інформація існує. Формули — елементарна арифметика, а розпізнавання їжі — задача, яку vision-моделі вирішують за секунду. Бази нутрієнтів — відкриті. Рішення такого типу вже є на ринку, але переважно це або платні calorie-трекери для широкої аудиторії, або вузькоспеціалізовані diabetes-інструменти без фокусу на простоту. Мені хотілось поєднати обидва сценарії в одному flow: «сфоткай → отримай точні цифри», з опціональним розрахунком дози для тих, кому це треба.

FoodShot — моя спроба це зробити. Telegram-бот, купа рішень, які я прийняв правильно, і ще більша купа — які мені ще відгукнуться.

Що робить бот (30 секунд)

Користувач надсилає фото їжі → vision-модель розпізнає страву й оцінює вагу → зовнішня база нутрієнтів повертає КБЖУ → користувач коригує порцію кнопками → опціонально вводить поточний рівень глюкози → бот рахує дозу інсуліну → зберігає запис у щоденник.

Увесь flow — 20-30 секунд замість 3-5 хвилин ручних розрахунків.

Перше архітектурне рішення, яке визначило все інше

Є спокуса дати LLM і розпізнавання, і нутрієнти, і розрахунок дози. Щоб був один запит — один результат, але я свідомо цього не зробив, і це — найважливіше рішення в проєкті.

AI використовується ТІЛЬКИ для одного кроку — розпізнавання фото. Нутрієнти беруться із зовнішньої верифікованої бази, розрахунки — детермінована математика. і ніяких LLM-generated медичних порад.

Чому:

  1. Галюцинації. LLM може впевнено сказати, що в 100г рису 45г вуглеводів. Або 28г. Або 52г. Залежить від настрою моделі. А різниця між 28г і 52г вуглеводів — це різниця між нормальним цукром і гіпоглікемією. Тому що коли мова про інсулін, «приблизно правильно» — це небезпечно.
  2. Аудит. Якщо нутрієнти приходять із верифікованої бази, я завжди можу пояснити, звідки взялося число. Якщо з LLM — ні. «Модель так вирішила» — це не відповідь, коли на кону здоров’я.
  3. Відтворюваність. Той самий запит до бази нутрієнтів завжди поверне ті самі числа. Той самий промпт до LLM — не факт.

Натомість vision-модель вирішує задачу, де вона справді непереможна — подивитися на фото і сказати «це курячий стейк, приблизно 200 грамів, впевненість висока». Це робота для vision, не для таблиці.

Vision Model ──→ назва страви + вага + впевненість
                       │
                       ▼
База нутрієнтів ──→ вуглеводи, білки, жири, ккал
                       │
                       ▼
Детермінована ────→ доза інсуліну
математика

Три шари. Кожен робить одну річ. Кожен можна замінити незалежно. Якщо завтра з’явиться модель краща за GPT-4o для food recognition — міняю один шар, решта не знає.

Vision API: задача простіша, ніж здається. Проблеми — ні.

Уся інтеграція з vision-моделлю — один файл, одна функція, один API-виклик. Технічно — нічого складного: base64-енкодинг фото, промпт із чіткою інструкцією, парсинг JSON-відповіді. Але є нюанс, який з’явився після першого ж тесту.

Проблема крос-мовного пошуку. База нутрієнтів (USDA FoodData Central) приймає тільки англійські назви. Але показувати українському користувачу «Chicken breast» замість «Куряча грудка» — це поганий UX.

Рішення: Просити модель повертати назву страви одразу двома мовами в одному запиті. Локалізована назва — для відображення в чаті, англійська — для запиту в базу нутрієнтів. Один виклик API замість двох, і ніякого окремого сервісу перекладу.

Це працює елегантно, але парсинг відповіді — моя відкрита рана. Vision-моделі іноді загортають JSON у markdown code block, іноді — ні. Я написав найпростіший можливий парсер, який це переживає, але він крихкий. OpenAI має Structured Outputs, які гарантують формат відповіді — це в планах на найближчу ітерацію. Поки що працює regex, і мій внутрішній аудит дивиться на мене з осудом.

Чого нема і що чесно лякає: error handling. Якщо API впаде, хендлер мовчки крашнеться. Для MVP з одним користувачем це прийнятний ризик, для чогось більшого це треба вирішувати.

USDA FoodData Central: коли база працює, але не так, як ти очікував

USDA FoodData Central — безкоштовна база Міністерства сільського господарства США. 380,000+ продуктів. Найбільша відкрита база нутрієнтів у світі. Є API. Є документація. Звучить ідеально.

А тепер реальність.

Проблема № 1: це база інгредієнтів, не страв.

Для «chicken breast» або «white rice» пошук працює відмінно. Для «pad thai» або «borscht» — вже лотерея. USDA знає «chicken, breast, without skin, raw», але не знає «chicken tikka masala». Я беру перший результат пошуку без будь-якого fuzzy matching — і для простих продуктів це достатньо. Для складних страв — потрібен fallback. Він у планах (є запасний API в конфігурації, але не підключений), а поки — працює «good enough».

Проблема № 2: одна і та ж величина в різних одиницях.

USDA повертає калорії і в kcal, і в kJ — в одному масиві нутрієнтів. Якщо не фільтрувати — можна підхопити кілоджоулі замість кілокалорій і нічого не помітити. Я знайшов цей баг, коли побачив, що «100г рису = 1528 kcal». Ні, це були кілоджоулі. Маленька перевірка на unit name, яка рятує від абсурдних цифр.

Проблема № 3: нестандартна номенклатура.

USDA не називає вуглеводи «carbs». Вони називають їх «Carbohydrate, by difference». Жири — «Total lipid (fat)». Це офіційна наукова номенклатура, і поки ти її не знаєш — будеш отримувати нулі там, де очікуєш числа. Я вбив ці рядки як string constants — не найелегантніше рішення, але документація USDA не пропонує enum чи ID-based альтернативу для Search endpoint.

Калькулятор болюсу: найменший і найважливіший модуль

Із усього коду в проєкті цей модуль найкоротший, і єдиний, де помилка — це не зламаний UI, а неправильна доза інсуліну.

Формула стандартна для diabetes management:

  • Вуглеводна доза = грами вуглеводів ÷ ICR (скільки грамів покриває 1 одиниця інсуліну)
  • Корекція = (поточна глюкоза — цільова) ÷ ISF (чутливість до інсуліну), тільки якщо поточна вище цільової
  • Загальна доза = вуглеводна + корекція

Формула — не секрет, вона є в будь-якому підручнику з ендокринології. Цікаве — в edge cases реалізації:

Ніколи не від’ємна корекція. Якщо поточний цукор нижче або на рівні цільового, тоді корекція нульова. Бот не скаже «вколи менше інсуліну». Це свідоме рішення про те, де зупинитись і не грати в лікаря.

Глюкоза — опціональна. Людина може пропустити введення і отримати тільки вуглеводну дозу. Реальний use case, бо не завжди під рукою глюкометр.

Округлення до 0.1 одиниці. Інсулінові ручки мають крок 0.5 або 1.0, але я показую з точністю 0.1, щоб людина сама вирішила, округлити вгору чи вниз. Рішення за нею, не за ботом.

Валідація ліниво, не жадібно. Дільники (ICR, ISF) перевіряються тільки коли реально потрібні для обчислення. Якщо людина не вводить глюкозу, тоді ISF може бути нулем, і нічого не зламається.

Це єдиний модуль із тестами, тут 7 test cases, включаючи граничні: нульові дільники, глюкоза рівно на таргеті, пропущений ввід, нульові вуглеводи. Решта проєкту — manual testing. Так, я знаю.

Важливий disclaimer, який є в самому боті: FoodShot — це НЕ медичний пристрій. Він рахує, але рішення приймає людина.

Conversation flow: FSM на Redis, що переживає перезапуски

Бот — це по суті стейт-машина. Aiogram має вбудований FSM із Redis-бекендом, що означає: якщо сервер перезапуститься посеред розмови — користувач продовжить з того ж місця.

Є три групи станів: реєстрація (послідовне введення медичних параметрів), аналіз їжі (від фото до дози), і редагування налаштувань. Плюс один мертвий стейт — залишок ранньої ітерації, коли я планував крок підтвердження страви перед запитом нутрієнтів. Потім вирішив, що кнопки коригування ваги (±10g/±50g) достатньо, і крок підтвердження зайвий. Стейт прибрати забув.

Цікаве архітектурне рішення в photo handler: коли приходять дані від бази нутрієнтів — я зберігаю їх у FSM state не як абсолютні значення, а як rates per gram. Коли користувач тисне «+10g», бот не робить новий запит до USDA. Він перераховує макроси з per-gram коефіцієнтів, які вже лежать у Redis. Одне звернення до зовнішнього API на фото, далі — чиста арифметика. Це і швидше, і дешевше.

Деталі, які не видно зовні

Коми vs точки. Українці пишуть «5,6», а не «5.6». Кожне числове поле конвертує кому в точку перед парсингом. Без цього — crash при першому ж введенні від українського користувача. Маленька річ, яку легко пропустити, якщо тестуєш тільки з англійською локаллю.

«Використати минулу глюкозу». Бот пам’ятає останній введений рівень глюкози і пропонує його кнопкою. Якщо людина виміряла цукор перед сніданком і їсть кілька разів — не треба вводити тричі.

Каскадне визначення мови. Спочатку — з бази (якщо вже зареєстрований). Якщо нема — з Telegram language_code (тільки якщо en або uk). Fallback — англійська. Три рівні, щоб покрити і нового, і існуючого користувача.

Подвійний шлях вебхука. Я використовую Cloudflare Tunnel замість nginx для проксі. Tunnel іноді дублює path у URL. Замість годин debug’ання — зареєстрував обидва варіанти endpoint. Костиль? Так. Працює? Теж так.

Стек

ЩоЧимЧому
Бот-фреймворкaiogram 3.xAsync, FSM із коробки, Redis storage
HTTP-серверFastAPI + uvicornWebhook endpoint, lifespan hooks
БДPostgreSQL + async SQLAlchemy 2.0Реляційна схема, asyncpg драйвер
СтейтRedisFSM переживає перезапуски
VisionOpenAI GPT-4oНайкраща якість food recognition на момент старту
НутрієнтиUSDA FoodData CentralБезкоштовний, офіційний, 380K+ продуктів
ДеплойDocker Compose + Cloudflare Tunnel4 контейнери: app, postgres, redis, tunnel

Увесь проєкт — близько тисячі рядків Python у 16 файлах. Це не humble brag — це обмеження MVP.

Що я зробив неправильно (і знаю про це)

Я провів внутрішній аудит і записав результати. Ось головне:

  1. Нуль error handling для зовнішніх API. Якщо OpenAI або USDA впадуть — бот мовчки крашнеться. Ні retry, ні graceful degradation. Для мене одного — допустимо. Для реальних користувачів — першочергово до виправлення.
  2. Нуль кешування. Кожне фото тієї ж «картоплі фрі» — новий запит до бази нутрієнтів. Redis вже стоїть для FSM — додати кеш технічно тривіально. Просто не дійшли руки.
  3. Зайві залежності. Я почав мігрувати i18n-систему з простого підходу на більш масштабний (Project Fluent). Написав менеджер, створив файли перекладів — а потім повернувся до початкового варіанту, бо для двох мов складне рішення — overkill. Залежності і файли лишились у проєкті. Dead code, який їсть місце і збиває з пантелику.
  4. Тести тільки на калькулятор. 7 тестів для розрахунку дози. Для всього іншого — manual testing.
  5. Debug print у продакшен-коді. Так. Я знаю.

Що далі

Технічний борг:

  • Structured Outputs замість regex-парсингу;
  • Кешування запитів до бази нутрієнтів;
  • Error handling з retry для зовнішніх API;
  • Нормальне логування замість print().

Фічі:

  • Експорт щоденника в CSV/Excel для лікаря;
  • Аналіз патернів глюкози (які страви викликають спайки);
  • Freemium: 2-3 безкоштовні аналізи на день, підписка за ~$2-3/місяць.

Поза скоупом MVP:

  • CGM інтеграції (Dexcom, Libre) — потребують медичної сертифікації;
  • Розбір складних страв на компоненти — «борщ» як окремо м’ясо + картопля + буряк.

Підсумок

FoodShot — це Telegram-бот, який з’єднує vision-модель, базу нутрієнтів і елементарну арифметику в один flow. Ключове архітектурне рішення — жорстка межа AI: модель розпізнає їжу, але не генерує нутрієнти і не рахує дози. Все, що стосується здоров’я, має бути детермінованим, відтворюваним і пояснюваним.

Якщо маєте досвід із медичними pet-проєктами, diabetes management, або просто хочете розказати, наскільки мій regex для парсингу JSON — погана ідея — буду радий у коментарях.

Документація: GitHub

👍ПодобаєтьсяСподобалось2
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

А чому в пошуку не можна бота знайти?

Дійсно спроба гарна, але не робоча. Я як діабетік, можу це говорити з точністю. Що стосується інсуліну не може бути ніяких АІ, це не приблизно, це повинно бути точно. І таблиці які є вони вже розроблені з розрахунком грамів, точних розрахунків. Не може бути приблизних. В Англії є такий додаток Carb&Calc. Там вже все розраховано, по грамах та по порціях, якщо цікаво завантажте розгляньте. Є ціла індустрія, якої, нажаль, немає в Україні на розрахунок вуглеводів, спеціальни курсы, та Healthy Living Centre де допомагають розібратися з розрахунком. Потрібно розуміти що доза інсуліну це равно граммам углеводів яки зараз плануєш з’їсти. І все повинно бути прораховано та зведено тут і зараз. Ніяких приблизні картинки взяти за основу не допоможуть , це буде некоректно. Повинно буди точно зважена їжа, а точніше вуглеводна їжа. Це основний інгредієнт на який прораховується доза інсуліну. Також є інші що можуть вмістити рівень цукру, навіть молоко містить незначну кількість, та її теж потрібно прораховувати, не в грамах, а мілілітрах. Тобто, все вже давно придумано, беріть діючи моделі ( аое я не впевнена що це можно зробити не як плагіат??), та вдосконалюйте. Мало того, вже прораховані інгредієнти по штрих коду, можна їх сканувати, та цей додаток дає інформацію скільки потрібно інсуліну. Також прораховані фаст фуд ресторани, та інші заклади. Якщо нужно більш інформації напишіть мені в месенджер, чим зможу тим допоможу, в Україні такі додатки потрібні.

Надіє, дякую за такий розгорнутий і чесний коментар, це саме той фідбек, заради якого я і виклав статтю публічно

Хочу уточнити один важливий момент: AI у проєкті не рахує дозу інсуліну взагалі. Сам розрахунок (carb dose, correction dose) це прозора формула на ICR/ISF, без жодного ML. AI відповідає лише за одну річ, розпізнати страву і оцінити вагу з фото. І тут ви абсолютно праві оцінка на око ніколи не замінить зважування, це фізичне обмеження будь-якого photo-based підходу, не питання якості коду.
Тому чесно: калькулятор дози — це опціональна, допоміжна фіча для орієнтиру, а не заміна точного підрахунку для тих, хто вже впевнено рахує вуглеводи. Основна цінність, яку я бачу для проєкту це журнал харчування з експортом лікарю та відстеження патернів, а не точний дозувальник.

Якщо у вас є час дуже хотів би почути в особистих, які саме додатки/підходи реально працюють для вас, і що найбільше бракує в українських реаліях. Це якраз те, чого мені не вистачає реального досвіду людей, котрi цим живуть щодня.

Ярослав. Цікава ідея, але судячи з опису, то не дуже юзабельна.
Калькулятор буде давати похибку, яка не дозволе їм користуватися. Журнал харчування?... Ну як варіант, але треба змінити концепт.
Основна помилка, як на мене, то це фокусування на фото їжі, а не складники які є в порції.

Дякую за фідбек! Погоджуюсь з головним — фото не дає точної грамовки, і для дозування інсуліну це критично, я це розумів ще на етапі проєктування. Тому реальний фокус зараз — це щоденник харчування з експортом (що, коли, і як це вплинуло на цукор), а фото-розпізнавання просто швидкий старт, який завжди можна відкоригувати вручну (точна грамовка, заміна страви тощо). Калькулятор дози опціональна допоміжна фіча, не заміна власного досвіду підрахунку, особливо якщо людина вже роками цим займається. Плюс збираю фідбек від реальних юзерів, щоб зрозуміти, куди рухати продукт далі, це вже другий коментар, який підказує змістити фокус з «точності по фото» на «гнучкість + журнал». В Україні такого формату реально бракує, тому продовжую над цим працювати.

Неможливо визначити все по формулах. Це все дуже індивідуально і НЕ МОЖЕ бути постійно. З свого досвіду.

погоджуюсь, індивідуальна варіативність реальна, і формула тут дає лише orientation point, а не готове рішення без корекції лікарем/власного досвіду. Саме тому в дисклеймері наголошую, що це не заміна консультації

Підписатись на коментарі