Diet Assistant

What it is

A diet and lifestyle assistant my wife and I use through Telegram. You type what you ate or send a photo of the plate, and it returns calories and macros. It tracks weight and water against a daily target and starts a conversation on its own each morning and evening.

There is no app to install and no dashboard to open. Everything happens in a chat window that was already on our phones, and the whole record lives on a machine I control.

A day with it

The bot drives most of the interaction. It asks for a weight in the morning, nudges about water during waking hours, takes meal logs whenever you send them, and closes the day with a summary of what you ate against your target.

07:00 Weigh-in prompt the trend, not the raw number all day Log a meal text or photo, whenever you eat hourly Water nudge only while you are awake 21:00 Daily summary eaten, remaining, deficit The scheduler starts the conversation; you never have to open an app first.
Figure 1. A day, as the bot drives it.

Logging a meal

A log starts as a plain message. The text is parsed into individual food items with quantities, calories and macros, and comes back as a draft you can act on. Saving is a deliberate step, so an estimate you disagree with never becomes a record.

You Bot Store “2 eggs and a paratha” or a photo of the plate Save · Edit · Discard nothing is stored until you say Parse to typed items schema-validated, not free text Draft card Egg ×2 · 156 kcal Paratha ×1 · 260 kcal Today: 820 / 2100 kcal remaining, plus deficit SQLite on my machine one row per item save edit daily summary and reminders come back the same way
Figure 2. Logging a meal. Nothing is written until you confirm the draft.

Photos work the same way, with one difference. Calorie estimates from a single image are biased low and get worse as the portion grows, so the app applies a correction before showing the draft, and asks for a one line note when the picture is a drink or a mixed dish where the estimate would be a guess.

Weight is handled separately. You send a number, and the app reports an exponential moving average over your recent weigh-ins. Day to day weight moves too much with water, salt and time of day to be worth reading on its own.

How it is put together

The code is split into a delivery layer and a core. The delivery layer knows about Telegram: message handlers, the allowlist that limits the bot to two users, and the scheduled jobs that send the proactive messages. The core knows nothing about it.

Delivery layer transport/ Telegram handlers, allowlist, routing scheduler/ APScheduler jobs, proactive messages import boundary, enforced by a test may import never imports Portable core core/ use-cases nutrition/ text and photo to items metrics/ BMR, TDEE, weight EMA storage/ SQLite, WAL mode LLM provider Gemini behind an interface Swapping Telegram for another front end leaves everything below the line unchanged.
Figure 3. The delivery layer may import the core. The core may never import the delivery layer.

That boundary is the design decision I care most about. Nutrition, metrics, storage and the use-cases in core/ are forbidden from importing the delivery layer, and a test walks the imports and fails if the rule is broken. It means the half of the app worth keeping is portable: moving off Telegram would replace the top two boxes and leave everything below untouched.

The model provider sits behind an interface for the same reason. Gemini is the default because the free tier covers two users, and swapping it is a single implementation, not a rewrite.

From a message to a stored row

Every model call returns a typed object validated against a schema. Nothing parses free text, so a malformed answer fails loudly instead of writing nonsense. Each task also has a small set of labelled examples it is scored against, which is what lets me change a prompt and see immediately whether the estimates got worse.

Raw input text or image Model call typed output only Plausibility guard impossible kcal dropped Stored row editable, undoable Labelled eval set scores every prompt change Item count capped a bad parse cannot flood Retry with backoff fails fast on rate limit Nothing reaches the database until every check passes.
Figure 4. From a raw message to a stored row, with the checks that sit in between.

Between the model and the database sit two guards. A plausibility check drops items with impossible calorie counts, and the item count is capped so one bad parse cannot write unbounded rows. The transport layer routes through the guarded function rather than the raw parser, so the checks cannot be bypassed by a different entry point.

Running it

It runs as a managed service under launchd or systemd and restarts on failure. Startup fails on a broken database instead of half working, an unhandled error in one message is caught and answered rather than killing the polling loop, and a nightly script takes a snapshot that is safe to run while the bot is still writing, keeping the last fourteen.

Status

Running daily for two users. Text and photo logging, weight and water tracking, daily summaries and scheduled check-ins are working, with 119 tests over the core. Meal suggestions are next.

Type
Personal project, in progress
Stack
Python 3.12, python-telegram-bot, APScheduler, SQLite, Gemini behind a provider interface
Users
Two, by allowlist

Related