Skip to content

paultursuru/nutrack

Repository files navigation

NuTrack

NuTrack is a personal nutrition tracker built with Ruby on Rails 8.1. You describe what you ate in plain text, and the app figures out the rest: it identifies the food, estimates the quantity, fetches official French nutritional data, and tracks your daily intake against your caloric profile.

⚠️ Disclaimer — not a medical tool. Nutritional data comes primarily from the CIQUAL database, but when no reliable match is found, the app falls back to LLM-estimated values generated by Mistral. LLMs are probabilistic by nature — they do not retrieve ground-truth data, they approximate it. The resulting figures may be inaccurate. NuTrack is not a substitute for professional advice. If you have specific dietary goals or health concerns, consult a registered dietitian or nutritionist.

How it works

  1. You type a food description — e.g. "un grand verre de jus d'orange" or "200g de riz basmati cuit".
  2. The app searches the CIQUAL database — the official French food composition table maintained by ANSES — and retrieves the nutritional profile for the closest matches (up to 5 results).
  3. A Mistral LLM agent picks the best match and estimates the quantity in grams from your description.
  4. The backend converts all nutrients to milligrams (except calories, kept in kcal) so the frontend can display values in g, mg, or µg as needed. Around 65 nutrients are captured per food item, including macronutrients, minerals, vitamins, fatty acid breakdown, and cholesterol.
  5. Your dashboard shows a real-time nutrition summary — daily intake vs. your personal caloric needs, a 3-day history, macro/micro totals for the day, and a 30-day calorie chart.
  6. Recommendations — a daily coaching summary based on the user's last 30 days of food intake is generated by a Mistral LLM agent and streamed in real-time via Turbo Streams.

Stack

Layer Technology
Framework Rails 8.1, Ruby
Database PostgreSQL with JSONB for nutrition data
Background jobs Solid Queue
Real-time updates Solid Cable + Turbo Streams
Frontend Hotwire (Turbo + Stimulus), Tailwind CSS
Charts Chartkick + Groupdate
LLM RubyLLM with Mistral (mistral-small-latest)
Nutrition data CIQUAL (ANSES)
Auth Rails has_secure_password (bcrypt)
Asset pipeline Propshaft + Importmap
Job monitoring Mission Control Jobs (/jobs)

Architecture

LLM integration (RubyLLM)

The app uses RubyLLM to interact with Mistral. Three agents handle distinct responsibilities:

NutritionAgent — food identification from CIQUAL results. Returns a structured response with the food name, estimated quantity in grams, and the index of the best CIQUAL match. It does not return nutritional values — all nutrient extraction is handled deterministically by NutrientConverter.

NutritionLabelAgent — parsing of manually pasted nutrition label text (e.g. copied from a food package). Extracts values per 100g and normalises units to mg.

CoachAgent — generates personalised daily nutritional targets from the user's physical profile (age, gender, height, weight, activity level, goal) using the Mifflin-St Jeor equation. Results are stored in users.nutritional_needs (JSONB) and regenerated automatically whenever the profile changes.

NutritionCoachAgent — analyses the user's last 30 days of food intake and produces a written coaching summary (positive points, deficiencies, concrete food suggestions). The result is streamed in real time via Turbo Streams and persisted in users.last_coaching.

CIQUAL data — primary source

CiqualSearchTool queries the CIQUAL Elasticsearch endpoint at ciqual.anses.fr with fuzzy matching. It returns raw nutritional compositions per 100g with their original units (g, mg, µg).

NutrientConverter then:

  • Maps CIQUAL English keys (e.g. "Vitamin B1 or Thiamin (mg/100g)") to clean snake_case keys (vitamin_b1)
  • Parses the source unit from the key name
  • Converts everything to milligrams (g × 1000, µg × 0.001, mg × 1) — except calories which stay in kcal
  • Scales values to the actual quantity consumed (value × quantity / 100)
  • Handles CIQUAL quirks: "< 0.5" and "traces" become 0

LLM fallback — when CIQUAL has no match

If the CIQUAL search returns no usable result (no match or poor match confidence), EnrichFoodItemJob falls back to NutritionLabelAgent, which asks Mistral to estimate the nutritional values directly. These values are LLM-generated and therefore approximate. This is a best-effort fallback, not a scientifically reliable source. The distinction is not currently surfaced in the UI.

Enrichment pipeline

When a FoodItem is saved with a new description, an EnrichFoodItemJob is enqueued via Solid Queue:

FoodItem saved
  → EnrichFoodItemJob (async)
    ├─ description contains nutrition label keywords?
    │   yes → NutritionLabelAgent.ask (LLM extraction from label text)
    │   no  → CiqualSearchTool.execute (CIQUAL API)
    │            → NutritionAgent.ask (Mistral: pick best match + estimate quantity)
    │            → NutrientConverter.convert (deterministic unit normalisation)
    │            → [fallback if no CIQUAL match] NutritionLabelAgent.ask (LLM estimation)
    └─ FoodItem.update!(name, quantity, nutrition_data)
         → Turbo Stream broadcast (real-time UI refresh)

Caloric profile & nutritional needs

Users set their physical profile (weight, height, birth date, gender, activity level, goal). Two things are calculated:

Daily caloric needs — computed deterministically in Ruby using the Mifflin-St Jeor equation with an activity factor:

Activity level Factor
Sedentary 1.2
Light 1.375
Moderate 1.55
Heavy 1.725
Very heavy 1.9

Personalised nutritional targets (nutritional_needs) — generated by CoachAgent (Mistral) when the profile is saved. Targets cover macronutrients (min/max ranges), key micronutrients (calcium, iron, magnesium, vitamins), and hydration. Stored as JSONB on the user and regenerated automatically via an after_commit callback whenever any profile field changes.

Nutrition summary

The dashboard (NutritionSummary service) provides:

  • Daily intake vs. targets — for calories and each tracked macro/micronutrient, with a deficit/surplus indicator
  • 3-day and 30-day history — aggregated intake for longer periods
  • 30-day calorie chart — daily calorie intake over the last month (via Chartkick)

All aggregation is done in SQL directly on the JSONB nutrition_data column for performance. Only keys from an explicit allowlist (FoodItem::NUTRITION_KEYS) are accepted in SQL queries.

Setup

git clone <repo-url> && cd nutrack
bundle install
bin/rails db:create db:migrate

# Required environment variable
export MISTRAL_API_KEY=your_key_here or add it to your .env file

bin/rails server

The app requires a running PostgreSQL instance. Database configuration is in config/database.yml (defaults to nutrack_development).

Useful tasks

# Re-enrich all food items (re-runs enrichment pipeline for every item)
bin/rails food_items:refresh

Data storage

Nutritional values are stored in a single JSONB column (food_items.nutrition_data). All values are in milligrams except calories which is in kcal. Example:

{
  "calories": 44.8,
  "proteins": 700.0,
  "carbohydrates": 9190.0,
  "fat": 200.0,
  "fiber": 200.0,
  "sugar": 8400.0,
  "salt": 0.0,
  "vitamin_c": 50.0,
  "vitamin_b1": 0.09,
  "retinol": 0.003,
  "vitamin_d3": 0.0,
  "alpha_tocopherol": 0.21,
  "vitamin_k1": 0.16,
  "iron": 0.079,
  "calcium": 10.5,
  "potassium": 200.0,
  "cholesterol": 0.0,
  "omega3_epa": 0.0,
  "omega3_dha": 0.0
}

This allows the frontend to display in whatever unit makes sense (g for macros, mg for minerals, µg for vitamins) by simply dividing or multiplying via FoodItemsHelper#adapt_unit.

License

Private project.

About

NuTrack is a personal nutrition tracker built with Ruby on Rails 8.1. It uses RubyLLM, Mistral, Turbo (frames and streams) and data from CIQUAL

Resources

Stars

Watchers

Forks

Contributors