NataCoach / product & system design Wiki Brain Personas Coach Console ↗

05 — Meal Lens: Food Photo Macros Coach Feedback

How a meal photo in the @NataCoachBot chat becomes an itemized macro estimate, a coach-grade verdict in Nata's voice, and a one-tap correction loop that makes the next estimate better.

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:

  1. 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.
  2. 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.
  3. 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.corrected events into wiki/food-priors.md — a topic page split out of nutrition-patterns.md per 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:

  1. 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.
  2. 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).
  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.
  4. 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) and wiki/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_on03 §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).