App link (🇬🇧/🇫🇷): https://nutrition-plan.streamlit.app/
Build a daily menu from free-text ingredients and compare the totals to your macro targets. A free LLM turns your text into food items and gram weights; every nutrition value comes from USDA FoodData Central. The LLM never produces nutrition numbers.
- Enter your daily kcal and macros, or estimate them (Mifflin–St Jeor TDEE, activity level, protein in g/kg). Targets are kept in the URL, so bookmark it to restore them.
- Create your daily meals (one tab each) by listing ingredients and quantities in natural language (English or French). "Daily extras" holds anything eaten outside meals (supplements, a protein scoop…).
- Generate your macros: each line is parsed, matched to a USDA food and weighed in grams
- Adjust any match or weight; totals and progress bars update instantly
- Download your menu as a summary CSV or a tidy long-format CSV
(
meal, food, grams, kcal, carbs, protein, fat, fiber)
If no AI provider is available (no key, outage, or the per-session cap of 30 parsing calls), the app switches to manual mode: search USDA foods and enter grams yourself. Manual mode can also be toggled in the sidebar.
Python 3.10 or newer (3.12 recommended and used for the tests).
python3.12 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt # app
pip install -r requirements-dev.txt # + black, pytestCreate .streamlit/secrets.toml (never commit it):
FDC_API_KEY = "..." # required — free key: https://fdc.nal.usda.gov/api-key-signup
GROQ_API_KEY = "..." # optional — primary LLM provider (free tier)
OPENROUTER_API_KEY = "..." # optional — fallback LLM provider (free models only)
# Optional overrides (defaults in nutrition/config.py)
# GROQ_MODEL = "openai/gpt-oss-20b"
# OPENROUTER_MODEL = "google/gemma-4-31b-it:free"
# LLM_SESSION_CAP = 30On Streamlit Community Cloud, paste the same keys in the app's Secrets settings.
Run the app:
streamlit run app.py- Parsing — one LLM call per meal (
nutrition/parser.py). Rows are sent with their numbers; the answer is validated with Pydantic as{"items": [{"row", "name_en", "usda_query", "grams", "assumption"}]}. An invalid answer is retried once with the validation error; rows that still fail are offered for manual search. Parses are cached globally by normalized text, so repeated meals cost nothing. - Providers — tried in order: Groq (
GROQ_MODEL), then OpenRouter (OPENROUTER_MODEL, a:freemodel). On a 429 the provider'sRetry-Afteris honoured: a short wait (≤ 10 s) is slept through, a longer one puts the provider on cooldown for that long (60 s when unknown). On 5xx / timeout / invalid JSON the next provider is tried. Missing keys simply remove that provider from the chain. Free tiers cap tokens per minute; each parse costs about 1k input tokens. - Nutrition —
nutrition/usda.pysearches FDC (Foundation + SR Legacy, values per 100 g) and scales by grams. Energy uses nutrient 1008, falling back to the Atwater energies (2048, 2047), kJ (1062), then computed Atwater. - Guardrails — 120 characters per row, 20 rows per meal, 30 LLM calls per session (cache hits are free).
Ingredient text is sent to the configured AI provider for parsing only. All figures are estimates, not medical or dietary advice.
pytest # all HTTP and LLM calls are mocked
black --check .eval/cases.jsonl holds ~44 labelled French and English rows (household
units, raw vs cooked, brands, multi-food lines, ambiguous portions).
python eval/parser_eval.py --probe # first: are the configured models available to your keys?
python eval/parser_eval.py # uses env vars or .streamlit/secrets.toml
python eval/parser_eval.py --no-usda # LLM only
python eval/parser_eval.py --lang fr --limit 10It prints one line per case, then the JSON failure rate, the median absolute
grams error and the top-1 USDA match accuracy, and lists every mismatch.
When a provider is rate limited it waits as long as the provider asks and
carries on; after three consecutive provider failures it stops. It is never
run by pytest.
This app was made using streamlit, USDA FoodData Central, Groq and OpenRouter.