Meal Lens is the highest-frequency touchpoint in NataCoach: 3–5 interactions per user per day, versus 3–4 workouts per week. It is where the propose-confirm pattern earns its keep — the user's entire job is take a photo of the plate. Everything else (itemization, macros, comparison against Nata's food program, the coaching verdict) is the system's job, per the prime directive: the user does less.
1. Pipeline
flowchart TD
A["Photo arrives in chat"] --> B["Quick ack under 2 s: 'Got it — looking at your plate'"]
B --> C["Enqueue meal-lens job (BullMQ)"]
C --> D["Haiku-tier router: food photo / menu / label / not food?"]
D -->|"not food"| D2["Reply conversationally, no meal logged"]
D -->|"food"| E["Vision LLM (Opus-tier): itemize dishes + portion estimates"]
E --> E2["Inject user food priors from Wiki Brain (usual plate, typical portions)"]
E2 --> F["Macro computation: kcal, protein, carbs, fat, fiber + confidence"]
F --> G["Compare vs day-so-far events + Nata food program targets"]
G --> H["Coach feedback drafted in Nata voice, grounded in Wiki Brain (goal, preferences, history)"]
H --> I{"Autonomy dial for meal feedback"}
I -->|"draft mode"| J["Macro card sent now; coach take queued to Coach Console"]
J --> J2["Nata approves or edits (under 30 s) → coach take sent"]
I -->|"auto-send"| K["Card + coach take sent together, audit-logged"]
J2 --> L["Reply carries propose-confirm buttons: Looks right / Adjust"]
K --> L
L --> M["meal.scored event → Postgres + raw/ in wiki repo"]Stage-by-stage commitments:
| Stage | Owner | Target latency | Notes |
|---|---|---|---|
| Quick ack | grammY webhook handler | < 2 s | Deterministic text, no LLM call — the user must never wonder if the photo landed |
| Routing | Haiku-tier | < 3 s | Classifies photo type; menus and labels branch to their own handlers (§5, §7) |
| Itemize + portions | Opus-tier vision | < 30 s | Prompt includes the user's food-priors page from the Wiki Brain |
| Macro computation | Deterministic code over the itemized list | < 1 s | Per-item macros from a food table keyed by the LLM's food IDs; LLM never does arithmetic we can do in code |
| Compare vs targets | SQL over today's events + food program | < 1 s | Targets come from the program Nata authored (structure in §8), never inferred |
| Coach take | Opus-tier + persona layer | < 15 s draft | Autonomy dial per Coach-in-the-loop: pilot starts in draft mode |
A deliberate split: the macro card is always sent immediately, even in draft mode, because it is a labeled estimate produced by deterministic computation over the itemization — low risk, high value. Only the coach take (Nata's voice, a judgment) passes through the autonomy dial. In draft mode the take arrives a few minutes later, which reads naturally in chat ("Nata's note on this one: …"). Meal feedback is the first message type we expect to graduate to auto-send with audit, because volume is high and stakes per message are low.
2. The reply format
Every reply has three parts, in this order:
- Compact macro card — itemized lines with portions, totals rounded to 5 g / 25 kcal (see §6), a confidence tag, and day-so-far vs target.
- Coach take — 1–2 sentences in Nata's voice, grounded in the user's Wiki Brain (goal, tone preference, what she has said to them before). Never a lecture.
- Swap suggestion — only when needed. A good meal gets praise, not homework.
Example A — Marta, home-cooked lunch (good meal, encouraging)
Marta's food program targets (set by Nata, per 10 §1.1): 1,900 kcal · 110 g protein · 215 g carbs · 65 g fat · 28 g fiber. She logged breakfast at 08:10 (oat porridge with milk + 2 eggs: 450 kcal, 25 P, 50 C, 15 F, 4 fiber).
Marta [13:38] <photo: plate with buckwheat, chicken thigh, cucumber-tomato salad>
NataCoachBot [13:38]
Got it — looking at your plate 🔍
NataCoachBot [13:39]
🍽 Lunch — estimate
• Grilled chicken thigh, ~150 g ....... 250 kcal
• Buckwheat, ~200 g cooked ............ 225 kcal
• Cucumber-tomato salad, olive oil .... 125 kcal
─────────────────────────────
~600 kcal · P 35 · C 50 · F 30 · fiber 8
Confidence: high (this is your usual plate)
Today so far: 1,050 / 1,900 kcal · protein 60 / 110 g
Nata: This is exactly the plate we set up — protein anchor, slow
carbs, veg. You're at 60 g protein with 50 to go: your cottage-cheese
snack plus dinner covers it easily. Love that you fit this into a
workday. 💪
[ ✓ Looks right ] [ ✏️ Adjust ]
Arithmetic check: 1,050 day-total = 450 (breakfast) + 600 (this card); protein 60 = 25 + 35. No swap suggestion — the meal is on-program.
Example B — Denys, hotel-restaurant burger (over target, blunt-kind + recovery plan)
Denys's home targets: 2,100 kcal · 160 g protein · 205 g carbs · 68 g fat · 30 g fiber (assumed TDEE ≈ 2,600 → ~500 kcal/day deficit). This is a travel week, so the day scores against his travel budget — up to 2,200 kcal with a 140 g protein floor (10 §2.1). He logged buffet breakfast (600 kcal, 40 P) and a work lunch (650 kcal, 45 P), so he enters dinner at 1,250 kcal / 85 g protein.
Denys [19:42] <photo: cheeseburger, large fries, 0.5 L lager>
NataCoachBot [19:42]
Got it — looking at your plate 🔍
NataCoachBot [19:43]
🍽 Dinner — estimate
• Cheeseburger, brioche bun ........... 850 kcal
• Fries, large ~150 g ................. 450 kcal
• Lager, 0.5 L ........................ 225 kcal
─────────────────────────────
~1,525 kcal · P 45 · C 125 · F 70 · fiber 6
Confidence: medium (restaurant portions vary)
Today so far: 2,775 / 2,200 kcal — about 575 over · protein 130 / 140 g
Nata: That's the full travel-day budget plus ~575 on top. One dinner
doesn't undo a week — but only if it ends here. Tonight: water or tea,
nothing else, and take your 20-minute walk before calls. Tomorrow:
eggs + yogurt at the buffet, skip the pastries, and your 45-min zone-2
is already on the plan. Do that and the week still lands near a
2,500 kcal deficit — on pace for ~0.3 kg down. Protein's 10 g under
your travel floor today; we let that go, calories matter more tonight.
Swap for next time: same burger, side salad instead of fries, zero
cola instead of the lager — that's ~575 kcal back and you barely
feel it.
[ ✓ Looks right ] [ ✏️ Adjust ]
Arithmetic check: 2,775 = 600 + 650 + 1,525; overage 575 = 2,775 − 2,200; protein 130 = 40 + 45 + 45; swap saves ≈ 450 (fries) + 225 (lager) − 100 (salad) = 575; weekly deficit: 3 home days × 500 + 4 travel days × 400 = 3,100 planned, minus tonight's ~575 ≈ 2,525 ≈ "near 2,500". The tone is blunt about the number, kind about the person, and always ends with a plan — that pairing is in Denys's Wiki Brain tone preferences and is enforced by the persona layer.
3. Correction UX
The card's numbers are a proposal. The correction loop is where propose-confirm pays compound interest:
flowchart LR
A["Card sent"] --> B{"User taps"}
B -->|"✓ Looks right"| C["meal.scored confirmed as-is"]
B -->|" Adjust"| D["Tap-to-fix item list (one button per line)"]
D --> E["Fix options: wrong food / wrong portion / add missed item / remove"]
E --> F["Free text or quick buttons: 'that is buckwheat, not rice'"]
F --> G["Recompute macros, card updates in place"]
G --> H["meal.corrected event stored"]
H --> I["Nightly distillation → food-priors page in Wiki Brain"]
I --> J["Future estimates start from this user's priors"]Rules of the loop:
- "Looks right" is the default and requires zero typing. No response within 2 hours also counts as implicit confirmation (logged with
confirmed: implicit), because hunting users for confirmations violates "the user does less." - Adjust shows one button per item line plus "+ add something". Tapping an item offers the four fix types; free text like "that is buckwheat, not rice" or "the portion was half that" is parsed by the Haiku-tier router. Recompute edits the existing card message in place (Telegram
editMessageText) — no chat clutter. - Every correction is an event, and events become priors. The nightly distillation job (see Wiki Brain) folds
meal.correctedevents intowiki/food-priors.md— a topic page split out ofnutrition-patterns.mdper the SCHEMA rules in 08 §2 — in that user's wiki. Excerpt from Marta's after week 3:
# Food priors — Marta
Updated: 2026-08-07 by distillation. Source: 41 meals, 9 corrections.
## Usual plate
- Grain is **buckwheat ~200 g cooked** (corrected from "rice" 3×) — assume buckwheat unless clearly not.
- Salad = cucumber + tomato + ~1 tbsp olive oil. She does not use store dressings.
- Dairy: kefir 2.5%, cottage cheese 5% (~150 g portions).
## Protein sources (frequency order)
chicken thigh, eggs, cottage cheese, canned tuna. Red meat rare (~1×/week).
## Portion calibration
- Her "portion" of porridge runs ~15% smaller than the model's default — bias estimates down.
This page is injected into the vision prompt for her next photo — which is why her card in §2 says "this is your usual plate" with high confidence. After ~2 weeks, correction rates in the pilot should fall visibly; we target < 20% of meals adjusted by week 4 (assumption to validate).
4. Non-photo inputs
Photos are the primary path, but Meal Lens accepts anything the user would naturally send:
| Input | Handling | Confidence |
|---|---|---|
| Free text: "ate a burger and fries" | Estimate from the user's priors (Denys's "burger" defaults to his usual ~850 kcal restaurant burger). At most one question, only if it changes the verdict ("Fries — regular or large?") | Medium-low, labeled |
| Voice note describing a meal | Transcribe, then same as free text. Important for Marta (one hand holding a baby) | Same as text |
| Forwarded restaurant menu photo, pre-ordering | The killer assist moment: read the menu, rank items against remaining budget for today, recommend 1–2 picks and one avoid | n/a — advisory |
| Packaged food label photo | Read the label — treat as ground truth (see §6 edge cases) | High |
The menu moment is the single best retention feature in Meal Lens, because it moves coaching from judging the past to shaping the next 10 minutes. Denys, 19:31, before the burger ever happens:
Denys [19:31] <photo: hotel restaurant menu> What should I pick here?
NataCoachBot [19:32]
You've got ~950 kcal of travel budget left and 55 g protein
to your 140 g floor. Best fits:
1) Chicken skewers + grilled vegetables — ~550 kcal, ~55 g protein.
Ask for an extra skewer → ~700 kcal, ~75 g protein. Best pick.
2) Beef fillet with side salad — ~600 kcal, ~50 g protein.
Skip the carbonara (~1,100 kcal, 35 g protein) — it alone blows the
day. If you're getting a drink, make it the one drink. 🍽
When the user follows a menu recommendation, the later photo confirmation is nearly free — the itemization is already known.
5. Accuracy stance
Portion estimation from a single photo is roughly ±20–30%. We say so in onboarding and we design for it rather than pretend otherwise. Why this is fine for coaching:
- Coaching decisions are directional, not decimal. Every action Meal Lens drives — "add a protein snack", "swap the fries", "end the day here" — is identical whether the burger was 800 or 900 kcal. There is no decision in this product that flips on a 10% macro error.
- The week is the unit; trend beats precision. A consistent per-user bias (Marta's small porridge portions, Denys's large restaurant plates) mostly cancels in week-over-week comparisons, and corrections shrink it (§3).
- Weekly weight trend is the ground truth for the energy side. If logged intake says −500 kcal/day but the weekly weigh-in trend (see Health Sync) is flat over 3 weeks, the calibration is off — the system proposes a target adjustment to Nata ("logged deficit isn't showing up on the scale; suggest −150 kcal or +1 zone-2 session") rather than arguing with the user about portions. The scale settles what the camera can't.
- Behavior beats both. A user who photographs 90% of meals with ±25% error is coached far better than one who quit a precise-but-tedious logging app. Meal Lens optimizes for the photo getting taken.
Never present false precision. Card totals round to 25 kcal and 5 g (fiber to 1 g); every card says estimate; confidence is shown as high / medium / low with a one-line reason ("restaurant portions vary"). We never show "487 kcal" — a number like that is a lie about how much we know, and per the safety rails, macros are always labeled estimates.
6. Edge cases
| Case | Behavior |
|---|---|
| Multi-dish table photo (family dinner spread) | Itemize every dish, then one question with buttons: "Which of these were yours?" — multi-select, then compute normally |
| Shared plate ("we split the pizza") | One question with quick buttons: "How much was yours? [¼] [⅓] [½] [all]" — scale macros by the answer |
| Packaged food label photo | Read the nutrition label via vision — treat printed values as ground truth (high confidence). Only question, if any: "The whole pack?" |
| Alcohol in frame | Counted honestly at 7 kcal/g as its own line item, no moralizing in-line. Frequency patterns surface in nutrition-patterns.md and the Weekly Review; policy on drinking belongs to Nata's food program, not to per-meal nagging |
| Half-eaten plate re-photo ("logging the leftovers") | Diff against the earlier photo of the same meal, emit a meal.corrected event reducing the original by the uneaten fraction — the user gets credit for what they didn't finish |
| Dim / blurry / ambiguous photo | One clarifying question max ("Was that chicken or pork?"). If still unclear, log with low confidence and a visible note rather than interrogating. A second question is never asked — friction kills the photo habit |
| Photo of someone else's food / not food | Router catches it; reply conversationally, log nothing |
The "one clarifying question max" rule is global across Meal Lens. Every question we ask taxes the exact behavior (frictionless photo logging) the whole pipeline depends on.
7. Aggregation: from meals to memory
Per the event-sourcing decision in the brief, every Meal Lens interaction is an immutable Postgres event (with originals in raw/ of the user's wiki repo):
| Event | Emitted when | Key fields |
|---|---|---|
meal.scored |
Card confirmed (explicit ✓ or implicit) | items[], macros, confidence, source (photo/text/voice/menu-follow) |
meal.corrected |
Any adjustment, incl. leftovers re-photo | before/after items, correction type |
meal.feedback_sent |
Coach take delivered | autonomy mode, approved-by, edit distance if Nata edited |
nutrition.day_closed |
Midnight rollup job | day totals vs targets, adherence %, meals logged count |
From events, deterministic SQL (never LLM output, per the brief) produces:
- Daily: day-close totals feed the next Morning Brief ("yesterday: 1,900 kcal, protein on target — good base for today's session").
- Weekly: adherence %, protein-target hit rate, logging rate, and restaurant-meal count flow into the user's Weekly Review and Nata's cross-client digest in the Coach Console.
- Nightly distillation updates two Wiki Brain pages:
wiki/food-priors.md(§3 — feeds estimation) andwiki/nutrition-patterns.md(feeds coaching). Excerpt from Denys's:
# Nutrition patterns — Denys
Updated: 2026-08-07 by distillation. Cross-refs: [travel-playbook](travel-playbook.md), [goals](goals.md)
## Reliable patterns
- Home weeks: 85–95% adherence, protein target hit 5–6 days/7.
- Travel weeks: adherence drops to ~60%; dinner is the failure point
(restaurant + 1–2 beers). Breakfast/lunch stay clean even when traveling.
- Responds well to pre-ordering menu assists: 4/5 recommendations followed.
## Coaching implications
- Push the menu-forward move at ~19:00 on travel days, before the restaurant.
- Never open with the overage number after a hard workday — plan first, number second.
- Weight trend 96 → 91.8 kg over 9 weeks (~0.45 kg/wk): estimates are
calibrated well enough; no target change needed.
That last line closes the loop: the weight trend validates the whole ±25% pipeline, per-user, with real ground truth. Meal Lens does not need to be a lab instrument — it needs to be a coach's eye that gets sharper every week, which is exactly what the correction events and the Wiki Brain make it.
8. Nata's food program: structure and authoring
Every verdict above is "relative to Nata's food program" — so the program must be a real object, not a vibe. Like training programs (06 §1), a food program is structured data Nata authors in the Coach Console program builder (09 §1), stored as a programs row (program_type = 'food', integer version, starts_on — 03 §3). "Nata's food program, v2" in Denys's wiki (10 §2.3) is exactly this object.
| Field | Example (Denys, v2) | Read by |
|---|---|---|
| Hard daily targets | 2,100 kcal · 160 g protein | Day-so-far line on every card; §2 verdicts |
| Soft bands | carbs 205 g ± 25 · fat 68 g ± 10 · fiber ≥ 30 g | Coach-take phrasing ("carbs front-loaded") — never nagged per meal |
| Expected meals/day | 4 (Marta: 3) | Meal-log coverage metric (09 §5); evening-close question |
| Context budgets | travel day: up to 2,200 kcal, protein floor 140 g | Travel-week scoring (§2 Example B); menu assists (§4) |
| Standing rules | protein-first buffet breakfast; two-drink budget; skyr backstop | Injected verbatim into the coach-take prompt as program text |
| Anchor meals | optional fixed meals (Marta's cottage-cheese snack) | Swap suggestions; protein-gap fixes |
Editing works like training-program edits: Nata changes a field in the builder, the new version takes effect at the next day rollover, and the program.updated event triggers an inline Wiki Brain ingest (08 §3.1). The "food-program tweak draft" that a protein alert can queue (09 §4) is a proposed diff to this object — Nata approves it like any other draft.
Siblings: 04-data-collection.md (weigh-ins, Health Sync ground truth) · 06-training-experience.md (how nutrition state feeds Session Mode) · 08-llm-wiki-brain.md (distillation mechanics) · 09-admin-analytics.md (approval queue and autonomy dial).