A nutritional analysis web app written in Python (FastAPI). Analyzes individual food portions, recipes, and complete meals using data pooled from six nutrition databases — USDA FoodData Central, Open Food Facts, the Canadian Nutrient File, and the UK CoFID, Australian AFCD, and French CIQUAL static datasets. The program presents itself to users as NutriMagnus ("nutrition wizard").
UPDATED: 2026-08-31:2233
Last monthly accuracy check: 2026-08-30.
- Overview
- Project Structure
- Setup
- Running the Program
- Architecture
- Web Interface
- Data Storage
- Test Suite
- Maintenance
NutriMagnus was designed from a preliminary specification (see 2025-02-26-python-nutritional analysis program.md) that called for:
- Calculation of bio-available complete protein combinations for one or more foods
- Nutritional analysis of individual foods, recipes, and meals
- Saving meals by date and saving recipes
The program pools six nutrition data sources:
- USDA FoodData Central — free API, 300,000+ foods with full macro/micronutrient profiles including amino acid data for protein completeness analysis. Requires a free API key.
- Open Food Facts — free, open-source (CC BY-SA 4.0), community-maintained, millions of branded/packaged products globally. No API key required. No amino acid data.
- Canadian Nutrient File (CNF) — Health Canada's reference database, no API key required. "Search" is a local name filter over one bulk food-list fetch cached for the life of the process, but per-food detail is still a live network call (see
cnf_api.py). Has amino acid data. - UK CoFID — McCance and Widdowson's Composition of Foods Integrated Dataset (2021), ~2,900 foods. No live API — a fully local, offline search over a bundled
cofid_data.jsonfile regenerated byscripts/build_cofid_data.py. No amino acid data. - Australian AFCD — Food Standards Australia New Zealand's Food Composition Database (Release 3), 1,588 foods. No live API — local search over
afcd_data.json(scripts/build_afcd_data.py). Has amino acid data. - French CIQUAL — ANSES's food composition table (2020), ~3,186 foods. No live API — local search over
ciqual_data.json(scripts/build_ciqual_data.py). No amino acid data.
numa/
db.py — SQLite database: schema, queries, context manager
usda.py — backwards-compatible re-export shim; import this
usda_api.py — USDA FoodData Central HTTP client; API key; search; detail fetch
usda_nutrients.py — nutrient math; AA analysis; DIAAS lookup; complement table; density
openfoodfacts.py — Open Food Facts API client; merged into food search results
cnf_api.py — Canadian Nutrient File API client; local name filter over one
cached bulk food-list fetch, live detail fetch per food
cofid_lookup.py — UK CoFID local search over bundled cofid_data.json (no live
API, no amino-acid data; see scripts/build_cofid_data.py)
afcd_lookup.py — Australian AFCD local search over bundled afcd_data.json (no
live API, has amino-acid data; see scripts/build_afcd_data.py)
ciqual_lookup.py — French CIQUAL local search over bundled ciqual_data.json (no
live API, no amino-acid data; see scripts/build_ciqual_data.py)
diaas.py — Meal-level DIAAS calculation and digestibility data
export.py — Report export (txt, md, html)
profile.py — User profile dataclass, RDA computation, unit parsing
platform_utils.py — Cross-platform data-dir path resolution
version.py — Single-source-of-truth VERSION stamp
requirements.txt — Python dependencies (fastapi, uvicorn, Jinja2, Markdown, python-multipart;
pytest/httpx/hypothesis for test/dev)
import_foods.py, import_json_folder.py, import_gi_seed.py
— standalone maintenance scripts, independent of the web app
(numa_gen_prompt.py, numa_import_claude.py also exist locally
as personal, gitignored scratch tools — not part of the repo)
build_oxalate_db.py — one-time script: builds oxalate.db from oxalate_source_data.py
README-numa-documentation.md — This file
scripts/
setup_venv.sh — Create and populate .venv
build_manual.py — Regenerates user-manual.html from user-manual.md
numa_app/
__init__.py
services/
aa_estimate.py — estimate a food's AA profile by scaling another food's AA
values to its own protein content: estimate_aa(), source_note()
claude_fetch.py — Claude AI amino-acid-fetch prompt building and response import
complements.py — shared complement-suggestion display math: aa_effects(),
two_step_combo(), build_complement_display()
csv_export.py — Food Cache CSV export: foods_to_csv(), compare_to_csv()
csv_import.py — Food Cache CSV import: parse_foods_csv(), import_foods(),
resolve_or_import_foods()
day_profile.py — per-day profile pinning: get_profile_for_date(), ensure_day_profile()
demo_data.py — starter foods/pantry/recipes, loaded from starter_data.json:
seed_if_fresh_install() (runs once on a truly empty DB),
load_demo_data()/clear_demo_data()/restore_selected() back the
Settings starter-data toggle
diet_aware.py — B12/iron/zinc bioavailability notes based on dietary preference
food_ids.py — classify_food_id() — food/recipe ID → (id_str, source_label)
food_import.py — shared food-cache import logic (used by import_foods.py etc.)
glycemic_load.py — shared glycemic load aggregation: compute_glycemic_load()
manual_build.py — rebuild_manual_if_stale(), used by the web app's /manual route
meal_bcp.py — shared meal-DCP fallback: recipe_dcp_fallback()
meal_list_columns.py — nutrient-column picker logic shared by Meals & Log, Recent
Days/Daily Summary, and the Nutrient Plot picker
nutrient_trend.py — multiday nutrient trend averaging
plotting.py — nutrient trend line-chart rendering
portions.py — _parse_portion_input() — portion-string parsing
print_sections.py — shared vocabulary/prefs resolution for printable
nutritional-analysis "what to include" checkboxes
rda_status.py — shared RDA/limit percent-of-target classification: rda_status()
recipe_csv.py — Recipe CSV export/import (recipes.csv + foods.csv pair,
including sub-recipes and ingredient nutrient/portion data)
recipe_dcp.py — shared auto-recompute of a recipe's per-serving DCP: recompute_recipe_dcp()
recipe_nutrients.py — shared recursive recipe-ingredient expansion:
expand_recipe_ingredients(), recipe_total_nutrients(), best_aa_nutrients()
search_ranking.py — shared food-search relevance ranking: relevance_key()
static_source_lookup.py — StaticSource class: shared local-search/lookup machinery
for bundled static datasets (CoFID/AFCD/CIQUAL);
cofid_lookup.py etc. are thin wrappers around it
top_contributors.py — rank a meal's/recipe's ingredients by contribution to one
nutrient (or DCP), for the "Top Contributors" analysis section
user-manual.md — Essential instructions, tips, and reference material for
users; plain-text sections keyed by {: #anchor} for inline display
oxalate.py — Read-only access to oxalate.db: get_oxalate_db(), search_similar(),
get_by_id(), format_oxalate(), category_label()
oxalate_source_data.py — Harvard T.H. Chan School of Public Health oxalate table compiled
as Python dicts (433 foods, Nov 2023); SOURCE_URL, SOURCE_DATE
build_oxalate_db.py — One-time script: reads oxalate_source_data.py and creates oxalate.db
Run: python build_oxalate_db.py
oxalate.db — Static SQLite reference database (tables: oxalate_foods, source_info)
Committed to repo; rebuilt by build_oxalate_db.py
web/ — Local web interface (FastAPI + Jinja2)
backend.py — All routes, helpers, and template context builders
launcher.py — Starts uvicorn and opens a browser tab
home_body.cache — Cached rendered HTML of home.md (invalidated when home.md is newer)
static/
style.css — Site-wide custom CSS
icon-256.png — App icon
vendor/bootstrap/ — Vendored Bootstrap 5 CSS+JS (offline use, no CDN)
templates/
base.html — Shared layout: navbar, vendored Bootstrap links, keyboard-shortcut JS
home.html — Landing page (rendered from home.md)
search.html — Food search results (USDA + cache + recipes)
_search_result_row.html, _search_api_rows.html, _add_food_row.html,
_add_food_api_rows.html — Search-results-table partials reused across food/meal/recipe add flows
_analyze_portion_result_row.html, _analyze_portion_api_rows.html
— Portion-analysis result-row partials
_source_filter_select.html — Reusable "Source" filter checkbox row (with select-all)
_result_limit_input.html — Reusable search result-count limit control
_diet_pref_quick.html — Inline dietary-preference quick-switcher partial
_rda_definition_footer.html — Shared RDA/limit-column definitions footer
_top_contributors.html — Top Contributors table partial (food/recipe/meal analyses)
_ul_column.html — Shared upper-limit column partial for nutrient tables
food_detail.html — Single food nutrient breakdown with RDA % and protein quality
food_analyze_portion.html — Select food + enter grams → nutrient table
food_analyze_recipe_portion.html — Select saved recipe + servings → nutrient table
food_convert.html — Portion ↔ weight conversion (density lookup)
food_compare.html — Side-by-side nutrient comparison (up to 6 foods, save/load)
food_cache.html — Browse/search cached foods; delete/archive from cache
food_cache_prune.html — Preview/confirm pruning of unused cached foods
food_cache_portions.html — Manage a cached food's USDA-style named portions
food_cache_import_csv.html — Import foods from a CSV file into the cache
food_cache_db_check.html — Database integrity check + repair for the food cache
food_custom_profiles.html — List user-drafted food profiles; create/delete
food_custom_edit.html — Edit a food's nutrients (drafted profiles and Food Cache share this)
food_annotate.html — Browse foods for GI/DIAAS annotation; edit annotation form
claude_fetch.html — Build a Claude AI amino-acid/nutrient fetch prompt for a food
claude_import.html — Import a pasted Claude AI response into the food cache
pantry.html — My Pantry: add/remove/archive foods on hand
meals.html — Meal list with Complete column, date filter, search link
meal.html — Meal view/edit: items, inline edit, add food/recipe, manage actions
meal_day.html — Full-day combined nutrient + DIAAS analysis across all meals on a date
meals_search.html — Search all meal history; flat occurrences + summary-by-food tables
recipes.html — Recipe browse/search list (Recent, complete/incomplete, broken refs link)
recipe_new.html — Create a new recipe
recipe_detail.html — Recipe view: ingredients, DCP, protein quality, print/export links
recipe_edit.html — Recipe edit: ingredients, instructions, servings, weight/volume
recipe_compare.html — Side-by-side recipe comparison (save/load, like food_compare.html)
recipe_import_csv.html — Import a recipes.csv + foods.csv bundle
recipe_broken_refs.html — List recipes whose ingredient references are broken; relink/substitute
analysis_food_use.html — Analysis: frequency of a food's use across meals/date ranges
analysis_food_use_recipes.html — Analysis: frequency of a food's use across recipes
summary.html — Daily summary landing page (Recent Days list)
trend.html — Multiday nutrient trend averaging view
nutrient_plot.html — Nutrient trend line-chart picker/display
nutrient_plot_print.html — Print-formatted nutrient plot
print.html — Shared printable nutritional-analysis page (food/recipe/meal/day)
settings.html — User profile, dietary preferences, USDA API key, DIAAS overrides,
nutrient targets, starter-data toggle, system-issues list
manual.html — Rendered user-manual.md
.venv/ — Python virtual environment (not committed)
The .venv directory is already created and populated. It uses Python 3.13 from the local miniconda installation, matching the cmgr project convention.
To recreate it from scratch using the setup script:
cd numa
./scripts/setup_venv.shOr manually:
cd numa
/home/tomc/miniconda3/bin/python3 -m venv .venv
.venv/bin/pip install -r requirements.txtDependencies (requirements.txt):
| Package | Purpose |
|---|---|
fastapi |
Web app framework |
uvicorn |
ASGI server |
Jinja2 |
Template rendering |
Markdown |
Renders the manual and other markdown text |
python-multipart |
Form-data parsing (FastAPI dependency) |
pytest, httpx, hypothesis |
Test suite / test client / property-based tests (test/dev only) |
Note:
requestsis not a dependency; HTTP uses stdliburllib.
Food searches require a free API key from the USDA FoodData Central service.
- Go to https://fdc.nal.usda.gov/api-key-signup.html
- Enter your name and email address — no payment or account creation required.
- The key is emailed to you immediately (check spam if it doesn't arrive within a minute or two).
The key is free and gives you 1,000 requests per hour, which is more than enough for normal use. There is no paid tier — this is the standard limit for all registered keys.
Note: The USDA API has a built-in
DEMO_KEYthat works without registration, but it is limited to roughly 30 requests per hour and will rate-limit quickly during a normal session. Always use a registered key for real use.
Set it from Settings → Advanced settings → USDA API key in the web app. It's saved to ~/.config/numa/config.json and used automatically on all subsequent runs.
python web/launcher.pyThis starts uvicorn and opens a browser tab at http://127.0.0.1:8000. Useful flags: --port N, --no-browser, --reload (dev auto-restart). Alternatively, from the web/ directory: uvicorn backend:app --reload.
For first-run setup, see the NutriMagnus User Manual.
numa has five top-level nav areas: Foods, Recipes, Meals & Log, Analysis, and Settings. Analysis is a growing collection of preset analyses — currently Daily summary - DCP and goals (the original per-day nutrient/RDA workflow), Food use in meals (frequency of food use across a chosen set of date ranges and/or meal IDs, with a substitution action), and Food use in recipes (the same frequency/substitution analysis, scoped to recipe ingredients instead of logged meals). For a complete description of every page and workflow, see the NutriMagnus User Manual.
For user-facing documentation of the food cache — what gets stored, the quick-pick flow, and how to view or delete entries — see the User Manual.
Overwrite protection for edited foods: Once you edit a food's nutrients through Food Cache (or create a food manually), it is marked user_drafted = True. Any subsequent USDA fetch for the same food — triggered by selecting it from a search results table — will not overwrite a user-drafted entry. Your manual edits, AA patches, and custom notes are permanent unless you explicitly edit or delete them.
Automatic omega fatty acid backfill: When a cached USDA food is selected and its stored nutrients are missing all four omega keys (omega3_ala_mg, omega3_epa_mg, omega3_dha_mg, omega6_la_mg), the program silently fetches and merges just those nutrients from the USDA API and updates the cache entry. This happens transparently on first use; subsequent accesses use the updated cache. User-drafted foods are never touched by this backfill.
The food_annotations table stores user-supplied estimates attached to individual cached foods by fdc_id (fields: gi_estimate, diaas_estimate, prep_context, gi_no_prompt, diaas_no_prompt). See the User Manual for the annotation workflow.
Prompt on first add: web/backend.py:_gi_prompt_needed() fires the first time a food is added to the Pantry (single-add path) or to a meal, if that food has no gi_estimate on file and gi_no_prompt isn't set. /pantry/add and /meal/{id}/add redirect to /food/annotate/{fdc_id}?next=... when a prompt is warranted; the annotate page offers "Skip for now" (no DB write, redirects to next) vs. "Skip forever for this food" (POSTs to /food/annotate/{fdc_id}/skip-forever, which sets gi_no_prompt=1 via upsert_food_annotation without touching other fields). diaas_no_prompt exists in the schema but is not currently wired into any add flow.
Seeding GI values in bulk: import_gi_seed.py (repo root) writes gi_estimate for cached foods whose name exactly matches an entry in a small hardcoded table sourced from Foster-Powell/Holt/Brand-Miller (2008) Diabetes Care 31(12):2281-3 (CC-licensed, ~60 common foods). Ambiguous/fuzzy matches are printed for manual review rather than written automatically — see the script's docstring. Run with --apply to write; without it, it's a dry run.
Visibility columns: The Ann column in search result tables (GI, DI, or GI DI in green) and the AA/GI/DIAAS columns in the cached food list are driven by joins against food_annotations keyed on fdc_id.
These three lists are related but distinct. Understanding the relationship prevents confusion when editing or annotating a food.
One source of truth — the foods table (the cache).
Every food — from any of numa's data sources (USDA, Open Food Facts, CNF, CoFID, AFCD, CIQUAL) or user-created — is a row in the foods SQLite table, uniquely identified by fdc_id. That row holds the name, serving metadata, nutrients_json, and a user_drafted flag.
| View | What it shows | Underlying table |
|---|---|---|
| Food Cache (Foods → 6) | All rows in foods |
foods |
| Drafted Food Profiles (Foods → 8) | Rows where user_drafted = 1 |
foods (filtered) |
| My Pantry (Foods → 7) | Pantry entries, each optionally FK'd to foods |
pantry |
Food Cache vs Drafted Food Profiles: The "Drafted Food Profiles" list is a filtered view of the same foods table — WHERE user_drafted = 1. A food becomes user_drafted = True when you create it manually or when you edit its nutrients through Food Cache. Editing a food in Food Cache immediately affects what Drafted Food Profiles shows, and vice versa, because they are the same row.
user_drafted = True also activates overwrite protection: all code paths that write a fresh USDA fetch result to the cache check this flag first and skip the write if it is set. This means manual AA patches, serving-size corrections, and other edits survive repeated searches for the same food.
Pantry: The pantry table has its own id (autoincrement), a food_name text field, and a nullable fdc_id FK pointing into foods. A pantry entry can exist without a cache entry (name-only). When fdc_id is set, all nutrient calculations use the live foods row — so editing that food in Food Cache instantly updates any pantry-based analysis. However, the pantry stores its own food_name string; renaming a cached food does not update the pantry display name.
Annotations: The food_annotations table is keyed by fdc_id and foreign-keyed to foods (ON DELETE CASCADE). Annotations are visible everywhere that food appears — search results, cache list, analyses — because they are always looked up by fdc_id.
Editing rule: All nutrient editing goes through Food Cache (Foods → 6). Both the Drafted Food Profiles menu and the Pantry menu redirect there for edits and display a notice to that effect. This keeps a single edit path regardless of how you reached the food.
Drafted profiles let you store custom nutrient values for any food. They are saved into the food cache with a small negative fdc_id (−1, −2, −3…), displayed as usr in tables, and flagged user_drafted = True so USDA re-fetches never overwrite them. They appear at the top of search results and are usable anywhere a regular cached food is. See the User Manual for the creation workflow.
Vitamins, minerals, and other supplement tablets are sold in per-tablet amounts, not per-100g amounts. Supplement mode solves this: by treating 1 tablet as equivalent to 100g internally, the stored per-100g values exactly equal the per-tablet label values. No weighing is required.
Creating a supplement: When you answer yes to "Is this a supplement?", the program asks for the unit name (default: tablet; other common values: capsule, softgel, pill, scoop). Serving size and unit are set automatically (1 tablet). Enter nutrient values exactly as printed on the label — e.g., if the label says "Vitamin B12: 5000 mcg per tablet", enter 5000 at the Vitamin B12 prompt. When you later log "1 tablet" in a meal, those exact amounts are added to your nutrient totals.
Editing an old entry to convert it to supplement mode: Open the entry via Drafted food profiles → 3. Edit. If the entry is user-drafted and not already in supplement mode, the program asks "Is this a supplement?" at the start of the edit session. Answer yes and confirm the unit name — the gram_weight=100 portion is added automatically, preserving any nutrient values you already entered.
IU input for vitamins A, D, and E:
Many US supplement labels express these vitamins in International Units. At those prompts, enter the number followed by IU (e.g. 400 IU or 5000iu). The program converts automatically:
- Vitamin A: 1 IU = 0.3 mcg RAE
- Vitamin D: 1 IU = 0.025 mcg
- Vitamin E (natural d-alpha-tocopherol): 1 IU = 0.67 mg
The conversion math is shown on screen so you can verify it against the label.
See the User Manual for usage documentation on food search, barcode scanning, and the food cache quick-pick.
Result ordering. See numa_app/services/search_ranking.py. relevance_key(name, query, source, data_type) returns a sort tuple, lower sorts first:
(-count, -mask, SOURCE_RANK.get(source, 9), DATA_TYPE_RANK.get(data_type, 1), exact, prefix, len(name), name)count— how many query words appear in the candidate's name (substring, case-insensitive). This is the dominant term: an all-words match always outranks an all-but-one match, which always outranks an all-but-two match, and so on.mask— a bitmask, one bit per query word, MSB = the first word typed. Within an equalcount, comparing masks as integers means matching earlier query words outranks matching later ones: for the querymilk dry instant(bit weights 4/2/1), a name matchingmilk+dryscores110₂ = 6, one matchingmilk+instantscores101₂ = 5— so the "dry" hit wins even though both matched exactly 2 of 3 words, because the user typed "dry" before "instant". This is a compact way to encode "word order signals priority" without hand-written tiering logic.SOURCE_RANK(pantry→0,cache→1,recipe→2,usda/off→3) — the only place source enters the comparison, and only oncecountandmaskare already tied. This is what fixes the previous behavior where a weak or coincidental pantry/cache match could outrank a much better USDA/OFF result simply by virtue of already being in the user's own data.DATA_TYPE_RANK(Foundation/SR Legacy→0,Survey (FNDDS)/Experimental→1,Branded/Open Food Facts→2) — breaks ties among results that are otherwise identical on text relevance and source. Without this, a wall of near-identical branded product names (e.g. a dozen listings all named "INSTANT NONFAT DRY MILK") can bury the one Foundation/SR Legacy food that actually carries amino acid data, since the remaining tiebreakers (below) have no way to prefer it — a longer, more descriptive USDA reference name loses to a terse branded one onlen(name)alone. This restores the property the original "search deeper into Foundation/SR Legacy" boost pass (seeget_search_boost_page_size()) was designed to provide by list position, beforerelevance_keystarted re-sorting its output.exact,prefix,len(name),name— final tiebreakers: exact string match, then prefix match, then shorter name, then alphabetical.
The web app additionally offers a "Pantry, Cache, then Other" sort mode (_sort_search_results() in web/backend.py) that sorts by (count, mask) — i.e. match quality — before SOURCE_RANK, same as relevance_key, but breaks ties on match quality by SOURCE_RANK before falling through to the rest of relevance_key's tiebreakers. So a food from the user's own Pantry/Food Cache/Recipes only sorts ahead of an external result when the two are otherwise tied on how well they matched the query — it can never displace a genuinely better external match. "Best match to name" (the default) uses relevance_key as shown above unmodified, with source only as a tiebreaker there too, just later in the tuple (after DATA_TYPE_RANK).
search_cached_foods() in db.py also excludes a _OR_FALLBACK_STOPWORDS set (generic prep/state words like "raw", "cooked", "fresh") from the any-word OR-fallback used for user_drafted foods — a food can't surface in search purely because it happens to share one of these generic words with the query.
The results table always includes an AA data column (✓ confirmed / ~✓ likely / ✗ none) and an Ann column showing which foods have GI and/or DIAAS estimates saved (GI, DI, or GI DI in green; ····· if none). Use these columns to pick the option with the richest existing data before committing to a fetch.
After viewing the full nutrient breakdown, the program now offers to immediately proceed to portion analysis for the same food — saving you from navigating back to "Analyze a food portion".
The USDA database contains many packaged/branded food entries. A simple search like "pinto beans" will often return 25 branded products (canned goods, mixes, etc.) rather than the plain cooked food you want.
For home-cooked or generic foods, add descriptive terms to narrow the results:
| Instead of… | Try… |
|---|---|
pinto beans |
pinto beans cooked |
chicken |
chicken breast cooked |
rice |
brown rice cooked usda |
oats |
oats rolled raw usda |
Adding cooked or raw targets the USDA Foundation Foods and SR Legacy datasets, which cover whole foods with full nutrient profiles (including amino acid data for protein completeness). Adding usda further filters toward those non-branded entries.
The program will display a tip automatically when most of your search results are branded products.
See Appendix J of the User Manual for the full list of accepted portion formats.
Pieces vs. weight (implementation note): A bare number (e.g. 2) is treated as pieces/count — no gram weight is recorded and the ingredient's nutritional contribution is zero in recipe/meal totals. To store a gram weight, the user must always include a unit. This distinction is enforced in _parse_portion_input() in numa_app/services/portions.py.
Nutrient values are stored per 100 g in the cache; all displayed values are scaled proportionally from that reference.
See the User Manual for usage documentation. Internally, the recipe's stored total-weight/total-volume fields plus the per-ingredient gram amounts are summed and scaled by the requested serving fraction.
Wherever protein is analyzed (food, recipe, or meal), numa checks whether all nine essential amino acids meet FAO/WHO reference levels. See the User Manual for output interpretation; see Appendix B of the User Manual for the theory behind FAO reference values and DIAAS.
When none of numa's data sources provide amino acid data for a food (Open Food Facts, CoFID, and CIQUAL never do; USDA, CNF, and AFCD sometimes lack it for a given entry), you can build a hand-crafted nutrient profile from a literature search and store it in the local cache via Foods → 7. User-drafted food profiles → 2. Create new user-drafted profile.
Workflow:
- Choose to start from a USDA food (pre-fills all available nutrients — you override only the AA fields) or from scratch.
- Enter the food name. Answer "no" to the supplement question (this is a whole food).
- Enter the serving size and unit.
- Step through macros, then optionally minerals, then optionally vitamins.
- Choose how to enter the amino acid profile:
- 1 — one-by-one (g per 100g food): step through each essential amino acid individually; all are optional.
- 2 — bulk import (g per 100g protein): paste or type a list of
name: valuepairs (e.g.lysine: 4.8); values are automatically converted to g per 100g food using the protein content you entered in step 3. Accepts full names, 3-letter codes (e.g.lys), and 1-letter codes (e.g.K). Non-essential amino acids are silently discarded; unrecognized names are flagged. A summary shows stored values with the conversion math. - n — skip: no amino acid data will be stored.
- Enter a Note documenting your source (e.g., "AA profile from Sarwar et al. 1985, J. Food Sci. 50(2)"). This is the field for source attribution.
- The profile is saved with a negative fdc_id and
data_type = "User Drafted". It is immediately available as a food in all search, meal, and recipe flows.
User-drafted profiles can be edited at any time (User-drafted food profiles → 3. Edit) and are listed with their notes in the user-drafted profiles table so the source is always visible. Deleting a user-drafted profile removes it from the cache permanently.
Copying a cached food as a draft — Foods → 7. User-drafted food profiles → 5. Copy a cached food as draft
This option copies any food already in the local cache — whether from USDA, Open Food Facts, CNF, CoFID, AFCD, CIQUAL, or an existing user-drafted profile — into a new editable draft. The workflow:
- Search the cache by name and pick the food to copy by ID.
- Confirm or edit the name (defaults to "Copy of …"), serving size, and serving unit.
- Step through all nutrient values pre-filled from the original — change only what you want.
- Edit or keep the note field, then save.
The copy is saved as a fresh user-drafted entry with a new negative ID, completely independent of the original. Any subsequent edits to either the original or the copy do not affect the other. This is useful for modeling variations of a food (different cooking method, fortification, preparation) while retaining the original cached entry unchanged.
Note field: Every food in the cache has a notes TEXT column. For user-drafted profiles this is the right place to record citation, confidence level, or any caveat about the data.
Some USDA entries — particularly SR Legacy and Branded foods — omit amino acid data. If the selected food has none, numa will display:
(No amino acid data available for protein completeness analysis.)
and immediately offer to search Foundation Foods for an equivalent entry. Foundation Foods is USDA's most curated dataset and almost always includes a full amino acid profile. The search is pre-filled with the first keyword(s) of the original food name (e.g., "beans, pinto, mature seeds, cooked, boiled, with salt" becomes "beans"). You can accept the suggestion, refine the query if needed, and pick from the Foundation results without leaving the current flow.
When no Foundation Foods substitute is available, numa provides a two-step workflow, backed by numa_app/services/claude_fetch.py, to retrieve amino acid (and other nutrient) data from Claude AI (claude.ai) and import it directly into the cache.
Access — Food Cache: check the boxes next to foods showing the uncertain/missing AA badge (or "Select all missing AA data"), then click Fetch missing data from Claude AI.
Step 1 — prompt generation (claude_fetch.build_prompt()).
Builds a prompt from the selected foods and shows it on its own page with a Copy prompt to clipboard button. The prompt instructs Claude to return one fenced JSON block per food containing:
- Metadata keys:
name,fdc_id,fdc_type,source,confidence_note - Nutrient keys: all recognized fields (macros, minerals, vitamins, phytonutrients, and all 11 amino acids), per 100 g edible portion
Key rules embedded in the prompt: amino acid values must be in grams per 100 g food (not per g protein, not mg); aa_methionine_g/aa_cystine_g and aa_phenylalanine_g/aa_tyrosine_g must always be separate keys; unknown values must be omitted entirely (never zero-filled); true zeros may be included explicitly; source hierarchy is USDA FDC → SR Legacy → peer-reviewed literature → estimate.
The page instructs the user to open a new claude.ai chat, paste the prompt and send, then copy Claude's entire reply back into NuMa's Import Claude response page.
Step 2 — response import (claude_fetch.parse_response() / validate_all()).
parse_response() extracts fenced (```json ```) and bare JSON objects from the pasted reply. Any non-JSON text trailing the last JSON block is collected as curator text — Claude's methodological caveats, confidence statements, and batch-level notes.
Each block is validated by validate_block(): it must have name (string), fdc_id (integer or integer-string), and a valid fdc_type; unrecognized nutrient keys are stripped silently; blocks that fail validation are reported and skipped.
Passing blocks are shown in a review table — name, FDC ID, calories, protein, and AA count out of 11 — before the user clicks Import. On confirmation, each food is written via _db.cache_food() (through claude_fetch.import_foods()) with:
notes— formatted fromsourceandconfidence_notecurator_notes— the batch-level curator textuser_draftednot set — entries remain overwritable by subsequent USDA re-fetches (omega backfill, incomplete-cache detection)
Import doesn't require the foods to be pre-existing cache entries — validate_block() only needs name and fdc_id, so a hand-pasted response (skipping Step 1 entirely) can introduce a brand-new food, e.g. a packaged product keyed by its UPC.
Per-serving input (numa_app/services/food_import.py). Nutrient values normally must already be per-100g. As an alternative, a block may give serving_size_g + nutrition_per_serving (same key names as the flat shape); validate_block() runs these through food_import.convert_per_serving() (scales by 100 / serving_size_g) before merging into nutrients, and appends the conversion factor to the food's notes. food_import.VALID_NUTRIENT_KEYS (derived from usda_api.NUTRIENT_MAP, so it can't drift from the nutrients numa actually understands) and food_import.validate_and_strip() are the single shared implementation of key validation/stripping — claude_fetch.py, import_foods.py, and import_json_folder.py all import from this module rather than keeping their own copies.
For stable, literature-sourced food records that need to survive repeated numa updates, import_foods.py is a standalone Python script that bypasses the interactive workflow. Food dicts are hardcoded in its _FOODS list (one per food, with the same nutrient key conventions as the Claude prompt template — including the serving_size_g/nutrition_per_serving alternate shape). Running the script imports all entries via cache_food(..., user_drafted=True).
The user_drafted=True flag is the critical difference from the Claude import path: it prevents USDA re-fetches from overwriting the imported data. Without it, the omega backfill or incomplete-cache detection paths in _fetch_food_from_result can silently replace a manually curated entry with raw USDA data (which for Branded foods typically lacks amino acids). Re-running the script is always safe — cache_food() uses INSERT OR REPLACE, so existing entries are updated in place.
The script does not include portion data; portions_json is stored as an empty array [] via json.dumps(portions or []).
A third, lower-ceremony import path for a single food: save one JSON file per food into food_imports/ (created on first run; gitignored) using the same block shape as a Claude-response entry (name, fdc_id, fdc_type, optional source/confidence_note, plus either flat per-100g nutrient keys or serving_size_g/nutrition_per_serving). Running python import_json_folder.py validates every file via the same food_import.validate_and_strip()/convert_per_serving() functions, prints a one-line summary per food, asks a single y/N confirmation, writes all of them via cache_food(..., user_drafted=True) in one with _db.get_db() block, then moves each processed file into food_imports/imported/ — re-running is still safe (INSERT OR REPLACE) even if a file were left in place or reintroduced.
Wherever a food is analyzed (search, portion analysis, recipe, or meal), numa automatically displays a Bioavailability section if it has data for that food. This section reports two things: the DIAAS score and any anti-nutrient advisories (see next section).
For background on the DIAAS scoring methodology and score interpretation, see Appendix B of the User Manual.
When a DIAAS score is known for the selected food, the Bioavailability section shows:
Bioavailability
Protein digestibility (DIAAS): 0.75 ███████████████░░░░░
Digestible protein: 11.3g (raw: 15.0g)
Note: Mineral absorption problem — phytates are present
* Best reduction: soak or sprout before cooking
* Moderate reduction: roasting
The bar and color indicate digestibility quality — green (≥ 0.90), yellow (≥ 0.70), red (< 0.70). The Digestible protein figure is simply raw protein × DIAAS score and represents what the body can realistically use.
Anti-nutrient notes appear below the DIAAS line when relevant. Each note names the problem category (e.g. Mineral absorption problem, Digestibility problem, Vitamin bioavailability problem), states the specific cause, and lists solutions as bullet points ordered by effectiveness. If a food matches multiple rules with the same underlying issue (e.g. a USDA food named "Beans, snap, seeds, mature" matching both the legume and nut/seed phytate rules), they are consolidated into a single note with all solutions listed.
If the food is not in the DIAAS lookup table, this line is omitted silently — it does not mean the food has poor digestibility, only that no lookup entry exists for it.
DIAAS scores are not available from any API — they come from controlled digestion studies conducted in laboratory settings. numa uses a static lookup table of ~60 common food categories, built from FAO 2013 reference values and peer-reviewed studies (principally Mathai et al. 2017, Br J Nutr; Gorissen et al. 2018, Amino Acids). The lookup uses keyword matching on the food name (case-insensitive, first match wins). More specific entries appear before general ones in the table so that, e.g., "chickpea pasta" resolves to the chickpea score (0.83) rather than the generic pasta/wheat score (0.46).
Notable entries: Collagen and gelatin are scored at 0.04 — effectively zero — because tryptophan is essentially absent from these proteins. Without this entry, collagen powder added to a recipe would be silently treated as fully digestible, substantially inflating the displayed digestible protein figure.
Lookup is not cached. The keyword scan takes microseconds and is repeated on each analysis. USDA nutrient data (macros, minerals, vitamins, amino acids) is cached in the foods table after first fetch and never re-fetched unless you delete the cache entry. DIAAS scores, by contrast, are derived from the food name at runtime and are not written back to the database.
Per-food DIAAS via annotations. If you have a primary-literature DIAAS value for a specific food, you can save it via Foods → Annotate a cached food or the inline prompt during analysis. A saved annotation in food_annotations.diaas_estimate takes priority over the keyword table for that food. This is the only path to storing a DIAAS value per-food in the database.
This same saved-value-takes-priority rule applies to the DIAAS column shown in the web app's Food Cache and My Pantry list views (food_cache.html, pantry.html): a saved annotation is marked with ★, otherwise the list falls back to the keyword-table value when the food has amino acid data.
Analyzing a meal or daily summary now shows a deeper Meal-level DIAAS Analysis section, computed by diaas.py, that goes beyond the per-food scores described above.
For the rationale behind meal-level pooling, see the User Manual.
For each ingredient:
- Look up the food's true ileal digestibility coefficient (0–1) — not the same as DIAAS. This is the fraction of each amino acid that is actually absorbed in the small intestine.
- Multiply each essential amino acid (IAA) amount by that digestibility factor to get digestible IAA grams.
Then across all ingredients:
- Pool the digestible IAA grams for each of the nine essential amino acids.
- Divide each pooled total by the FAO 2013 adult reference (mg/g protein) applied to the meal's total protein.
- The composite DIAAS is the ratio of the most limiting IAA. Digestible complete protein = protein from AA-analyzed ingredients × min(composite DIAAS, 1.0), capped at the digestibility-weighted absorbed protein from those same ingredients (
aa_dig_protein_gindiaas.py) — the raw multiplication can otherwise project more complete protein than was physically absorbed, when the limiting amino acid is concentrated in a higher-digestibility ingredient than the meal's average. See DCP cap for the full worked example.
This is the methodology from FAO Food and Nutrition Paper 92 (2013).
For an annotated example of the meal-level DIAAS output table, see the User Manual. Note: if tyrosine data is absent (not tracked before April 2026; re-fetch the food to get it), the Phe+Tyr row is flagged as a gap.
When a meal has ingredients without AA data, the analysis reports how many are affected and distinguishes two categories:
- Inside a recipe — the ingredient is part of a recipe logged as a meal item. To fix these, edit the recipe directly (Recipes → browse → edit ingredients) and replace or re-fetch the ingredient there.
- Standalone meal ingredients — foods logged directly to the meal (not inside a recipe). These can be replaced interactively: the program offers a
y/nprompt asking whether to search for a substitute.
If you answer y, for each affected standalone ingredient the program runs a focused search of USDA SR Legacy and Foundation foods — the datasets most likely to include full amino acid profiles. The AA column in the results table (✓/✗) shows at a glance which candidates have AA data. Picking a replacement updates that ingredient in the meal for the current analysis session. Press Enter to skip an ingredient and leave it excluded from IAA pooling.
Ingredients contributing less than 1 g of protein are treated as negligible and left off all of these missing-AA lists and digestibility tables — a footnote notes when items were omitted this way. This keeps garnishes, spices, and trace amounts (e.g. a square of dark chocolate) from cluttering warnings that matter only for real protein sources.
When meal analysis begins, _compute_meal_ingredient_list() (meals.py:610) loops over every food in the meal and calls _best_nutrients() for each one. Inside _best_nutrients(), _refresh_cache_if_missing_aa() checks whether the cached food has amino acid data. If it does not — and the food is Foundation or SR Legacy (i.e. USDA should have AA data) — it makes a synchronous USDA API call to re-fetch the full nutrient profile and update the cache.
The result is one sequential HTTP round-trip per ingredient that lacks cached AA data. For a meal with six foods where four were cached without AA data (common for foods first added via a quick text search), that is four back-to-back network requests, each potentially taking 1–3 seconds. After the first analysis the AA data is stored in the local cache, so subsequent analyses of the same meal are fast. The spinner is a one-time fetch-and-cache cost per food.
For each food, the digestibility coefficient is resolved in order:
- User override — an exact food-name entry you set in Settings → Advanced settings → Protein digestibility overrides. Takes precedence over everything else.
- Curated table (~50 entries in
diaas.py) — literature-sourced values for specific foods and categories, with citations. Covers all common plant proteins. - Category default — broad averages when no specific match is found (isolated plant protein: 0.92, legume: 0.80, seed: 0.82, nut: 0.78, grain/cereal: 0.82, animal: 0.96). The source description shows
~estin the output. - Overall default: 0.82 — if nothing matches, a conservative plant-protein average is used.
The source of each digestibility value is shown in the ingredient table. Estimated values are also listed in a footnote.
Settings → Advanced settings → Protein digestibility overrides lets you set a specific digestibility coefficient for any food you have found a primary-literature value for. This is a power-user feature — the curated table covers most common plant proteins and the category defaults are defensible estimates. Overrides are stored in the diaas_overrides table and survive across sessions. The interface shows you what value numa would use without the override before asking for your input.
Wherever protein is analyzed, numa checks for essential amino acid gaps and offers complement suggestions. See the User Manual for the user-facing workflow.
Suggestion sources — three tiers (in order):
- Pantry — foods from My Pantry that close the gap; ranked by gaps closed then smallest required amount (up to 3 per page).
- General table — a curated built-in table of common plant protein sources (protein + nine EAAs only; not usable in search/recipes).
- The user can page through both lists interactively.
Scoring formula for total DCP: (base_protein + complement_protein) × min(1.0, base_digestibility × new_pool_raw_min), where new_pool_raw_min is the minimum raw AA score (vs. FAO reference) across all essential amino acids in the combined pool. For DIAAS-improver suggestions: (base_protein + raw) × min(1.0, new_diaas) — sourced directly from suggest_complements() in usda_nutrients.py.
A complement that closes the primary gap but dilutes a different amino acid will show a lower total DCP — the pooled DIAAS drops because the new limiting AA is weaker than the base alone. A qualifying complement must bring the most limiting AA to exactly 1.0 (the FAO floor) in a practical serving (≤ 500 g).
Settings → Dietary preferences controls which protein sources appear in complement suggestions. See the User Manual for the three options. The setting is saved to ~/.config/numa/prefs.json (key: diet_pref) and applied to both interactive complement suggestions and exported reports.
See the User Manual for the recipe creation and editing workflow.
See the User Manual. DCP is not copied — it is recalculated the first time you analyze the new recipe.
See the User Manual for the meal logging workflow.
Reports are auto-saved to ~/.numa/reports/ after every analysis. Additional user-exported copies (md/txt/html) go to ~/.numa/user-requested-nutrition-reports/. See the User Manual for the full workflow.
Diet preference in exports: Exported reports filter complement suggestion sections by the active dietary preference. Implemented by passing diet_pref=state._diet_pref to export.build_report() from reports._offer_export(). The build_report(title, sections, fmt, diet_pref="all") signature accepts the preference and overrides the static complement_suggestions renderer for that call.
See the User Manual for usage.
usda.py is a thin re-export shim. All code that does import usda as _usda continues to work unchanged. The actual implementation lives in two files that can each be read and edited independently:
usda_api.py— HTTP client: API key management,search_foods(),get_food_detail(),_parse_food(), and theNUTRIENT_MAP/ESSENTIAL_AMINO_ACIDS/AA_REFERENCE_MG_PER_G_PROTEINconstants.usda_nutrients.py— all nutrient math:scale_nutrients(),sum_nutrients(),has_amino_acid_data(),protein_completeness(),get_aa_gaps(),suggest_complements(),get_diaas(),get_antinutrient_flags(),get_density_g_per_ml(), and the embedded DIAAS, anti-nutrient, and complement data tables.
The support modules (db.py, usda.py, diaas.py, export.py, profile.py) live at the project root and are imported by numa_app/services/* and web/backend.py as needed.
search_foods(query, page_size=8) — searches the OFF REST API (no key required). Returns result dicts in the same format as usda.search_foods(), tagged with _from_off=True and _off_data (the full product record). Fails silently on network errors so a slow or unavailable OFF server never breaks USDA searches.
get_food_detail(off_result) — builds a full nutrient dict from the already-present _off_data in the search result. No second HTTP call needed.
off_id(barcode) — converts an EAN/UPC barcode string to a deterministic negative integer fdc_id in the range −2,000,000,000 to −3,000,000,000. This keeps OFF IDs well separated from USDA IDs (positive) and user-drafted IDs (−1, −2, −3, …).
OFF nutrient keys (energy-kcal_100g, proteins_100g, etc.) are mapped to the program's internal keys. Mineral values (sodium, calcium, iron, etc.) are converted from grams (OFF convention) to milligrams (program convention) by multiplying by 1000. Amino acid data is not available from OFF.
_parse_portion_input(raw, portions, food_name) parses a portion string and returns (grams, label). Accepted formats:
- Bare number (e.g.
2) → pieces/count: returns(0.0, "2 pc"). No gram weight. - Piece unit (
pc,pcs,piece,pieces,each,ea,count,ct,item,items) → same as bare number. - Weight (
150 g,3 oz,0.5 lb, fractions, mixed numbers) → grams. - Volume (
1/4 cup,2 tbsp,1 tsp,ml) → grams via food density. - USDA portion shortcut (
p1,p2) → gram weight from USDA portions list. - Portion multiple (
1.5 p1) → multiple of a USDA portion.
Returns None on unrecognized input; (None, vol_display) when volume is recognized but density is unavailable (caller then prompts for grams). _PIECE_UNITS is a frozenset of the recognized piece-unit words. volume_hint() and amount_note() (also in this file) render a cups/tbsp/tsp approximation for a gram amount, used by complement suggestions and elsewhere. web/backend.py builds its own portion-picker UI around _parse_portion_input() directly.
numa_app/services/aa_estimate.py — AA copy/estimate: manually typing or pasting a food's amino acid profile is slow, and copying another food's raw AA grams verbatim silently misestimates whenever the two foods' protein density differs (a food with 2× the protein would otherwise look 2× more complete than it really is). estimate_aa(target_nutrients, source_nutrients) -> (updated, factor, error) scales every usda.ALL_AMINO_ACIDS key present in source_nutrients by target_protein_g / source_protein_g before writing it onto a copy of target_nutrients; returns an error string instead if the source lacks amino acid data (usda.has_amino_acid_data()) or either food has no usable protein_g. source_note(name, fdc_id, factor) renders the "AA data estimated by scaling from X (#id), factor N.NNx, DATE" string used to auto-suggest a Note. usda.ALL_AMINO_ACIDS (in usda_api.py, re-exported via usda.py) is ESSENTIAL_AMINO_ACIDS plus aa_cystine_g/aa_tyrosine_g — the full trackable AA set, as distinct from the 9-key essential set used for completeness scoring. Entry point: an inline picker on food_custom_edit.html (search your Food Cache, "Use as source" button) posting to POST /food/custom-profiles/{fdc_id}/copy-aa (web/backend.py), which fetches-and-caches the source if it isn't cached yet, applies estimate_aa(), and redirects back with an aa_applied status flag.
All persistence goes through a get_db() context manager that commits on clean exit and rolls back on exception. The database path is ~/.local/share/numa/numa.db.
Schema migrations run automatically at startup: web/backend.py's FastAPI lifespan handler (_lifespan(), wraps app = FastAPI(..., lifespan=_lifespan)) calls _db.init_db() on web server startup, so the app is self-sufficient against a brand-new or un-migrated database (see tests/test_web.py::test_web_app_self_migrates_without_cli, a regression test ensuring the web app never depends on a second process to run migrations). init_db() is idempotent (every migration step is a guarded ALTER TABLE ... ADD COLUMN or CREATE TABLE IF NOT EXISTS), so it's always safe and cheap to call.
Schema:
| Table | Purpose |
|---|---|
foods |
Local cache of USDA food entries (nutrients as JSON) |
recipes |
Recipe metadata (name, servings, description, total volume, total weight, dcp_g, last_accessed_at) |
recipe_ingredients |
One row per ingredient; foreign key to recipes |
meals |
Meal log entries with date |
meal_items |
Foods or recipes added to a meal; foreign key to meals |
pantry |
User's protein-source inventory (food name, optional fdc_id, notes) |
food_annotations |
Per-food user-supplied estimates (GI, DIAAS, prep context), keyed by fdc_id; also stores gi_no_prompt / diaas_no_prompt suppression flags |
diaas_overrides |
User-set true ileal digestibility coefficients, keyed by food name (used by meal-level DIAAS in diaas.py; distinct from per-food annotations above) |
saved_comparisons |
Named, saved Compare Foods lists (/food/compare/save and friends) |
saved_recipe_comparisons |
Named, saved Compare Recipes lists (/recipe/compare/save and friends) |
day_bcp_cache |
Cached per-day best-complete-protein figure, invalidated on relevant edits |
day_profile |
Per-date pinned profile snapshot — see numa_app/services/day_profile.py below |
recompute_errors |
Logged DCP-cascade recompute failures — see below |
(oxalate_links also lives in numa.db; documented separately under Oxalate Data below since it belongs to that subsystem.)
recipes.total_volume / recipes.total_volume_unit and recipes.total_weight / recipes.total_weight_unit store the user-entered batch size (e.g. 4.0 / "cups", 800.0 / "g"). Both pairs are nullable — either or both may be omitted. Added via ALTER TABLE migration so existing databases are upgraded automatically on first run.
recipes.dcp_g stores the digestible complete protein per serving, kept in sync automatically: numa_app/services/recipe_dcp.py's recompute_recipe_dcp() recomputes and persists it after any recipe or ingredient edit (web/backend.py recipe/ingredient POST routes), and db.py's mutating recipe functions (recipe_update, recipe_add/update/remove_ingredient) clear it to NULL as a fallback in case some caller doesn't. It is NULL (shown as NC — not computed) when servings is 0, no ingredient has weight, or a significant protein-contributing ingredient (≥1 g protein) is missing amino acid data; a best-guess/approximate value is never persisted. Minor protein contributors missing amino acid data (<1 g protein — spices, oil, salt, a trace of chocolate) are excluded from the calculation rather than blocking it, regardless of how large a share of the recipe's (possibly small) total protein that 1 g represents — the gram floor is absolute, not relative to the recipe. The web app's "Compute DCP for all complete recipes" button (/recipes/compute-bcp) also calls this same function across every recipe, regardless of its complete flag.
Food edits cascade the same way. recipe_dcp.py's cascade_food_change(fdc_id, conn) recomputes DCP for every recipe that uses a given food directly (via db.recipes_containing_food()), then cascades up through ancestors exactly as an in-recipe edit does. It's called after every write to foods.nutrients_json — the USDA "refresh" route, the custom-profile edit route, the AA-import ("estimate it from a similar food") route, and the various cache_food() re-cache call sites reachable from search/pantry/recipe/meal food-add flows (skipped only at the couple of call sites where the fdc_id is guaranteed brand-new, e.g. custom-profile create/copy, since nothing can reference it yet). Each affected recipe is recomputed independently — one failure doesn't block the rest of the cascade, or the food save that triggered it — it's logged to recompute_errors instead (see below) so it isn't silently lost.
recompute_errors table (db.py) records any cascade step that raises rather than letting a bare except Exception: pass swallow it. Columns: entity_type, entity_id, message, occurred_at, resolved_at (NULL = unresolved), banner_ack_at (NULL = not yet dismissed from the home-page banner). db.log_recompute_error() writes an entry; list_unresolved_recompute_errors() backs the Settings > System Issues list; list_unacked_recompute_errors() backs the home-page banner, silenced without resolving via ack_recompute_errors_banner() ("Got it, don't remind me again" — a fresh failure after that reappears in the banner). This is deliberately not used for expected non-computability (0 servings, missing AA data on a significant ingredient) — those already surface as NC in the UI and aren't errors.
The Settings-page action is Retry, not a plain dismiss: web/backend.py's settings_recompute_error_resolve() looks up the entry via db.get_recompute_error(), calls recipe_dcp.recompute_recipe_dcp() for that entry's recipe, and only calls resolve_recompute_error() if that succeeds. A retry that raises again calls db.update_recompute_error() to refresh that same row's message/occurred_at (and re-arm the banner by clearing banner_ack_at) rather than resolving it or inserting a duplicate row — an entry can never disappear from the list while its underlying recipe is still broken, and retrying repeatedly doesn't pile up multiple rows for the same recipe. Re-editing the recipe directly (which triggers the same recompute_recipe_dcp() call through the normal recipe-edit path) has the same clearing effect the next time that recipe's cascade succeeds, without needing to click Retry at all.
recipes.last_accessed_at stores the ISO 8601 UTC timestamp of the last time the recipe was opened via any workflow action (view/edit/develop/analyze/copy). It is NULL for recipes that have never been accessed. Added via ALTER TABLE migration. Used by recipe_list_recent() to order the Browse view; falls back to created_at for recipes with no access timestamp.
Key db functions:
| Function | Purpose |
|---|---|
recipe_list(conn) |
All recipes ordered by name; includes complete, last_accessed_at, total_weight, total_weight_unit columns |
recipe_list_recent(conn, limit=20) |
Recipes ordered by COALESCE(last_accessed_at, created_at) DESC — powers the Browse recent view |
recipe_touch(conn, recipe_id) |
Sets last_accessed_at = datetime('now') for a recipe — called whenever a recipe is opened |
meal_add_recipe(conn, meal_id, recipe_id, recipe_name, servings, unit="servings") |
unit parameter now configurable (was hardcoded to "servings") |
All nutrient data is stored as a JSON blob in foods.nutrients_json, keyed by the same field names used throughout (calories, protein_g, carbs_g, etc.). This avoids schema migrations when nutrient tracking is expanded.
Archiving (archived column): foods, pantry, and recipes each carry an archived INTEGER NOT NULL DEFAULT 0 column (added via the same ALTER TABLE ... ADD COLUMN migration idiom as the rest of the schema — see init_db()). Archiving is the "reserve area" mechanism: it lets a user hide a row from default use without deleting it or risking foreign-key integrity, which ruled out the alternative of a literal second database file (recipes reference fdc_id, meals reference recipe_id, etc. — a second DB would require cross-database copies or ATTACHed joins to keep those relationships intact).
list_cached_foods,search_cached_foods,pantry_list,recipe_list,recipe_list_recentall takeinclude_archived: bool = False— the default excludes archived rows, so every existing caller (including all ofweb/backend.py) got this filtering automatically without change.- Single-row lookups (
get_cached_food,expand_recipe_ingredientsinrecipe_nutrients.py) are never filtered — an archived food/recipe still resolves correctly wherever it's already referenced (an existing recipe ingredient, a logged meal item). set_food_archived/set_pantry_archived/set_recipe_archivedflip the flag; callers reuse the current-state check as a toggle, so one route handles both archive and restore.food_references(conn, fdc_id)/recipe_references(conn, recipe_id)return reference counts (pantry/recipes/meals) so the UI can warn — but not block — before archiving something still in active use.list_unused_cached_foods/prune_unused_cached_foodsalways exclude archived rows: archiving is meant to protect data from being lost, so an archived-but-unreferenced food is never swept up byu(prune unused).- Per-list "show archived" visibility is a session/persisted preference, not a query default —
numa_app/state.py'sAppContext.list_filtersdict (get_list_filter/set_list_filter) mirrors the existingsort_prefspattern exactly, andnuma_app/config/prefs.pypersists it (show_archived_food_cache/show_archived_pantry/show_archived_recipeskeys inprefs.json) the same way sort choices are persisted. Any future per-list view-state toggle should follow this same two-layer (state.py get/set + config/prefs.py persisted wrapper) pattern. web/backend.py's/food/cache,/pantry, and/recipesGET routes accept ashow_archivedquery param resolved via_resolve_bool_pref()(mirrors_resolve_sort()), and each has aPOST .../{{id}}/archiveroute that flips the flag and redirects with?archived=1/?restored=1(plus&still_used=1when the item was still referenced) for a flash-banner message. The web flow archives immediately (one click, no JS-driven confirm dialog) and surfaces the "still referenced" warning as a post-action flash message instead of a pre-action prompt — a warn-but-never-block policy, matching the app's plain-HTML-forms architecture (no fetch/AJAX anywhere inweb/).
API: USDA FoodData Central REST API (https://api.nal.usda.gov/fdc/v1). Uses stdlib urllib — no requests dependency at runtime.
NUTRIENT_MAP is a dict mapping USDA nutrient IDs (integers) to (our_key, display_label, unit) tuples. It covers 45 nutrients: macros, minerals, vitamins, 7 phytonutrients/bioactive compounds, and 11 amino acids (including tyrosine, added to support Phe+Tyr combined scoring in meal-level DIAAS). Also defines ESSENTIAL_AMINO_ACIDS (list of 9 internal keys) and AA_REFERENCE_MG_PER_G_PROTEIN (FAO 2013 reference pattern).
Key functions:
| Function | Purpose |
|---|---|
get_api_key() / set_api_key(key) |
Read/write API key from ~/.config/numa/config.json |
search_foods(query, page_size=15, data_types=None) |
Search USDA; returns list of result dicts |
get_food_detail(fdc_id) |
Fetch full nutrient profile for one food |
get_search_boost_page_size() / set_search_boost_page_size(n) |
Read/write the result cap (default 25) for the Foundation/SR Legacy-only "boost" search — see below |
_parse_food() normalizes USDA API responses — the API returns nutrients in three different formats depending on the endpoint (nutrientId, nutrient.id, or number), and _parse_food handles all three.
search_foods(query, page_size, data_types): page_size=0 means no cap — it requests _USDA_MAX_PAGE_SIZE (200, USDA's own per-request ceiling) and returns every filtered match, skipping the normal [:page_size] truncation. For any unrestricted (data_types=None) search, USDA's own relevance ranking can bury plain/raw foods — the ones most likely to carry amino-acid data — well below branded or prepared-dish matches (e.g. "Potatoes, flesh and skin, raw" ranks ~20th for the query "potato", behind "Bread, potato" and several potato-chip variants). Every caller that does an unrestricted search (the Foods search page, the meal add-food panel, and a recipe's ingredient search) works around this with a second, explicit data_types=["Foundation", "SR Legacy"] pass merged in ahead of the general results; that pass's page_size is get_search_boost_page_size(), user-configurable via the Settings page's USDA API Key panel, 0 meaning no cap.
_SEARCH_ALIASES is a small static list of foods that the USDA search index fails to surface reliably (e.g., flaxseed, chia, hemp seed, oats). These are injected into every search result that matches the alias keywords.
Imports NUTRIENT_MAP, ESSENTIAL_AMINO_ACIDS, and AA_REFERENCE_MG_PER_G_PROTEIN from usda_api. The bulk of this file is three large static data tables (DIAAS, anti-nutrient, complement) with the functions that query them.
Key functions:
| Function | Purpose |
|---|---|
scale_nutrients(nutrients, amount, base_size=100) |
Scale a nutrient dict from base_size to amount |
sum_nutrients(*dicts) |
Add any number of nutrient dicts together |
protein_completeness(nutrients) |
Assess essential amino acid completeness vs. FAO/WHO reference. Requires 5+ AAs with non-zero values; zero-keyed AA entries (common in branded USDA foods) are ignored. |
get_aa_gaps(nutrients, digestibility=1.0) |
Return (aa_key, score, deficit_g) for each essential AA with digestibility-adjusted score below 0.95, sorted most-limiting first. The 0.95 threshold filters out near-adequate AAs (e.g. score 0.994) that would otherwise generate impractically small complement amounts. |
suggest_complements(base_nutrients, pantry_candidates, diet_pref="all", cache_candidates=None, exclude_names=None) |
Compute minimum-gram complement suggestions from pantry, the broader food cache, and the curated table; returns {"pantry": [...], "general": [...]}. diet_pref controls which curated-table entries are eligible: "all" includes everything, "vegetarian" includes only plant and dairy/egg entries (those flagged dairy_egg=True in _COMPLEMENT_TABLE), "plant_only" excludes all animal entries. The curated table holds protein + nine essential AAs per 100g for ~30 common protein sources; used only for complement scoring and AA gap augmentation — not for general food search. Any real candidate (pantry, recipe, or cache_candidates — real foods matched by name to a curated entry, built by callers via complement_table_names() + db.search_cached_foods()) that has real macros but no amino acid panel of its own is auto-estimated by scaling the matching curated entry's AA profile to its own protein content, rather than being silently dropped; result dicts carry "estimated": True when this happened. The general tier prefers a cache_candidates match's real data/fdc_id over the curated entry's own generic profile when one is found. exclude_names (case-insensitive) omits given suggestion names entirely from every tier — pantry, general, pairs, and diaas_improvers all derive from the same two filtered candidate pools — used by the web app's per-suggestion "ignore" checkboxes on /food/{id}, /meal/{id}, /recipe/{id}; threaded through numa_app/services/complements.py::build_complement_display(). Each route takes two repeated query params: ignore_complements (every currently-ignored name — both hidden inputs carrying forward prior ignores and any newly checked suggestion-card boxes) and unignore (names checked in the "manage ignored foods" panel to restore); backend.py::_effective_ignored() computes the final exclude set as ignore_complements - unignore before passing it to exclude_names, and re-renders the same effective set into the next page's hidden inputs so ignores accumulate across repeated recalculations instead of being overwritten. See numa_app/services/complements.py::load_cache_candidates() and the manual's "Amino acid estimates in complement suggestions" section for the user-facing side of this. |
nutrient_label(key) |
Reverse-lookup display name and unit for any nutrient key |
get_diaas(food_name) |
Return DIAAS protein digestibility score for a food (keyword lookup) |
get_antinutrient_flags(food_name) |
Return consolidated anti-nutrient flags as a list of {"problem": str, "cause": str, "solutions": [(label, description), ...]} dicts. Entries sharing the same group are merged into one flag with multiple solutions. |
get_density_g_per_ml(food_name, portions) |
Estimate g/ml density for volume-to-weight conversion. Static table takes priority over USDA portion data. |
Protein completeness method: The FAO 2013 dietary protein quality evaluation reference pattern (mg of each essential amino acid per gram of protein) is used. Met+Cys and Phe+Tyr are evaluated as combined pairs per FAO 2013, with reference values of 22 and 38 mg/g protein respectively. A protein digestibility factor (DIAAS) is applied to scores before the complete/incomplete determination so the classification reflects bioavailable amino acids. A food is complete when all digestibility-adjusted AA scores ≥ 1.0; the most limiting AA is the one with the lowest adjusted score. Raw (pre-digestibility) scores are shown in the display table.
DIAAS lookup (get_diaas): A static ordered table of ~60 food categories maps keyword patterns to DIAAS scores from FAO 2013 and peer-reviewed digestion studies. Used for single-food bioavailability display. Scores above 1.0 are capped at 1.0. More specific entries are listed before general ones — first match wins. If no entry matches, returns None and the caller treats the food as fully digestible (1.0). Results are not written back to the database; the scan is repeated on each analysis (microsecond cost). Distinct from the digestibility coefficients in diaas.py, which are used for meal-level pooled calculation. Notable: collagen/gelatin is scored 0.04 (near-zero tryptophan) — without this entry these proteins would be silently over-credited.
Anti-nutrient flags: A static table maps food keywords to advisory messages (phytate, oxalate, lectins, trypsin inhibitors, bound niacin). Flags can be suppressed by cooking-state keywords — e.g., "cooked"/"boiled" suppresses lectin and trypsin inhibitor warnings for beans.
Density lookup: get_density_g_per_ml first checks the food's USDA portion list for any cup or tablespoon entry and derives density from it. If none is found, it falls back to a static keyword table covering ~50 food categories. Returns None if density cannot be determined.
Re-exports all public names from usda_api and usda_nutrients so that every import usda as _usda call in the codebase continues to work unchanged. Edit usda_api.py or usda_nutrients.py directly; usda.py itself never needs to change.
Implements the FAO 2013 meal-level DIAAS methodology: apply a true ileal digestibility coefficient per ingredient, pool the resulting digestible IAA grams across all ingredients, then score the pool against the FAO adult reference pattern.
Constants:
| Name | Purpose |
|---|---|
FAO_REFERENCE |
FAO 2013 adult reference pattern — mg of each IAA per g of total protein |
IAA_LABELS |
Human-readable names for the nine IAA keys |
Digestibility data — three tiers:
| Name | Entries | Source |
|---|---|---|
_DIGESTIBILITY_TABLE |
~50 specific foods | FAO FNP 92 (2013); Mathai et al. 2017; Gorissen et al. 2018; other literature |
_CATEGORY_DEFAULTS |
6 broad categories | Literature averages |
_OVERALL_DEFAULT |
0.82 | Conservative plant-protein average |
The Met+Cys and Phe+Tyr IAA pairs are handled via _IAA_PAIRS: the secondary key's value (cystine for Met+Cys; tyrosine for Phe+Tyr) is added to the primary before scoring. If tyrosine is absent from a food's USDA record, the phe_tyr_gap flag is set in results.
Key functions:
| Function | Purpose |
|---|---|
get_digestibility(food_name, conn) |
Three-tier lookup: user override → curated → category → default. Returns (digestibility, source_description). |
meal_level_diaas(ingredients, conn) |
Pool digestibility-corrected IAAs across all ingredients; compute composite DIAAS, limiting IAA, and digestible complete protein. Returns a detailed result dict. |
diaas_override_set/get/list/delete |
CRUD for the diaas_overrides table. |
UserProfile dataclass: age, sex ("male"/"female"/"other"), weight_kg, height_cm, activity_level, weight_unit ("kg"/"lb"), height_unit ("cm"/"imperial"). The unit fields control display formatting only; internal calculations always use kg and cm.
parse_weight(raw) and parse_height(raw) accept free-form strings ("80 kg", "176 lbs", "178 cm", "5'10"") and return (value_in_base_unit, detected_unit).
compute_rda(profile) returns a dict mapping nutrient keys to (rda_value, unit, rda_type) tuples where rda_type is "target" (recommended intake, e.g. calories), "minimum" (RDA or Adequate Intake — most nutrients), or "limit" (Tolerable Upper Intake Level, e.g. sodium). Calorie target uses Mifflin-St Jeor × activity multiplier. Protein scales with weight and activity (0.8–1.2 g/kg). All other targets follow NIH/IOM Dietary Reference Intakes with sex-specific and age-adjusted values.
Profile is saved to and loaded from ~/.config/numa/profile.json.
The use_oxalate_data: bool = False field enables Harvard oxalate data lookup (see below). It is opt-in and defaults to False for all new and existing profiles.
Profile Optimal targets and custom max limits (optimal_targets: dict, max_limits: dict fields, both nutrient_key -> float, native unit): user-configured overrides layered on top of the standard RDA. compute_optimal(profile) returns only the nutrients present in optimal_targets, in the same (value, unit, "target") shape as compute_rda — absent nutrients are simply not in the dict, so callers can distinguish "not customized" from "customized to zero". get_max_limits(profile) returns a copy of max_limits. Both are edited via Settings → 7. Nutrient Targets and persist through the same save_profile/load_profile JSON round-trip as the rest of the profile — load_profile() explicitly reconstructs UserProfile field-by-field, so any new dataclass field must be added there too or it will silently fail to reload despite being written correctly by save_profile.
numa_app/services/rda_status.py's limit_warning(day_total, limit) returns True once a day's total reaches 90% of a configured max limit (or exceeds it) — independent of rda_status's built-in "limit" tier for nutrients like sodium that already have a Tolerable Upper Intake Level baked into compute_rda. _nutrient_sections (in web/backend.py) accepts optional optimal/max_limits dicts: when optimal is non-empty, a second "Profile Optimal" triplet of columns (meal %, day total %, goal) is added next to the RDA triplet, with nutrients lacking a configured optimal shown as a dash rather than falling back to RDA; when a nutrient's day total triggers limit_warning, its row is colored warning/error.
numa_app/services/day_profile.py — per-day profile pinning: since a user can maintain several named profiles and switch the active one over time (illness, travel, weight change), any RDA/DCP comparison for a specific logged date must never resolve the profile via a bare profile.load_profile() — that always returns whichever profile is active right now, which is wrong for a past date. This module is the required entry point for any date-scoped profile lookup:
get_profile_for_date(conn, meal_date)— returns theUserProfilepinned tomeal_date, pinning it first (to the currently-active profile) if it has none yet.ensure_day_profile(conn, meal_date)— pinsmeal_dateif unpinned; no-op otherwise. Called once from everydb.meal_create(...)call site (web/backend.py) so a day is pinned the first time a meal is saved for it.backfill_missing_day_profiles(conn)— pins every currently-logged date that predates this feature (or was otherwise never pinned) to today's active profile. Called once at startup (web/backend.py's FastAPI lifespan handler) so existing data doesn't require the user to touch a day for it to get a profile.set_day_profile_override(conn, meal_date, profile_name)— manually reassignsmeal_dateto a specific saved profile (for when illness/travel didn't line up with the calendar day) and flags the dayoverridden.protein_target_for_date(conn, meal_date, diet_pref)— the daily protein RDA target from the date's pinned profile; used whereverday_pct_goal(Meals & Log's "% profile goal" column) is recomputed.
The pin is a full numeric snapshot (json.dumps(dataclasses.asdict(profile))), not just a name reference — editing a profile's numbers later does not retroactively change a day already pinned to it. Schema: day_profile(meal_date PRIMARY KEY, profile_name, profile_json, pinned_at, overridden) in db.py, alongside day_bcp_cache.
Oxalate reference data is kept separate from the user's numa.db because it is static, read-only, and belongs to a third party (Harvard T.H. Chan School of Public Health).
oxalate_source_data.py — 433 foods as Python dicts; the authoritative source
build_oxalate_db.py — reads source_data, writes oxalate.db (run once)
oxalate.db — read-only SQLite file committed to the repo
oxalate.py — context manager + search/fetch helpers
numa.db (oxalate_links) — user-specific table: which food maps to which oxalate record
Harvard T.H. Chan School of Public Health, Oxalate Table (November 2023) Credit: Dr. John Knight, University of Alabama School of Medicine URL: https://hsph.harvard.edu/wp-content/uploads/2024/07/OXALATE-TABLE-1.xlsx Retrieved: 2026-06-22
CREATE TABLE oxalate_foods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
food_group TEXT NOT NULL,
food_name TEXT NOT NULL,
serving_size TEXT,
oxalate_mg_per_serving REAL,
oxalate_mg_per_100g REAL, -- NULL when serving is volumetric (cup, piece, etc.)
category TEXT, -- "very high" | "high" | "moderate" | "low" | "negligible"
directly_measured INTEGER DEFAULT 0,
source_note TEXT
);
CREATE TABLE source_info (key TEXT PRIMARY KEY, value TEXT);Per-100g values are computed during build for oz-based servings (1 oz = 28.3495 g). Volumetric servings (cups, tablespoons, pieces) cannot be converted without density data; those rows have oxalate_mg_per_100g = NULL.
Category thresholds (per 100g when available, per serving otherwise):
- very high: ≥ 300 mg/100g (or ≥ 100 mg/serving)
- high: ≥ 100 mg/100g (or ≥ 26 mg/serving)
- moderate: ≥ 25 mg/100g (or ≥ 10 mg/serving)
- low: ≥ 5 mg/100g (or ≥ 2 mg/serving)
- negligible: < 5 mg/100g
User-confirmed links between cached foods and oxalate records:
CREATE TABLE IF NOT EXISTS oxalate_links (
fdc_id INTEGER PRIMARY KEY REFERENCES foods(fdc_id) ON DELETE CASCADE,
oxalate_food_id INTEGER, -- oxalate.db row id; NULL when no_match=1
user_confirmed INTEGER DEFAULT 0,
confirmed_at TEXT,
no_match INTEGER DEFAULT 0 -- 1 = user confirmed no record applies
);get_oxalate_db()— context manager returning a read-only sqlite3.Connectionis_available()— returns True if oxalate.db exists (safe to call at startup)search_similar(conn, food_name, top_n=5)— difflib.SequenceMatcher ranking against a broad candidate poolget_by_id(conn, id)— fetch one rowformat_oxalate(row)— compact display string, e.g. "72.0 mg / 1 oz [high]"
python build_oxalate_db.pyThe script deletes and recreates oxalate.db from scratch. Run this after editing oxalate_source_data.py. The generated file is committed to git so end users never need to run the script.
A local FastAPI web app. All routes are in web/backend.py; templates live in web/templates/. See Running the Program for how to launch it.
cd web
uvicorn backend:app --reload # development (auto-reloads on save)
uvicorn backend:app # production-style (no reload)The app is then available at http://127.0.0.1:8000.
All FastAPI routes and backend helpers are in this single file. Key patterns:
_nutrient_sections(nutrients, rda)— converts a nutrients dict into a list of grouped display rows, each with RDA % and CSS class for colour-coding. Used by food detail, meal analysis, and day analysis._protein_section(food_name, nutrients)— builds the protein quality block (DIAAS score, amino acid ratio table) for a single food. ReturnsNoneif the food has no protein or AA data._meal_totals(meal_id)— returns(items_with_nutrients, total_nutrients_dict, diaas_result)for a meal. Expands recipe ingredients for DIAAS. Item dicts includeid,food_name,fdc_id,recipe_id,amount,unit,notes,has_nuts._day_analysis(meal_date)— aggregates nutrients and ingredients across all meals on a given date; returns(meals_list, combined_nutrients, diaas_result)._recipe_nutrients_per_serving(recipe_id, conn)— sums ingredient nutrients for one recipe, divides by serving count._load_rda()— loads the user profile and returns computed RDA dict, orNoneif no profile exists.
web/backend.py currently defines 142 routes. The tables below group the ones most useful to
know by name; several large, mostly-mechanical groups (per-row edit/move/archive endpoints, saved
comparison-list CRUD, print/export variants) are summarized as a single row rather than listed
individually — read web/backend.py directly (grep -n '^@app\.') for the exhaustive, current list.
| Method | Path | Description |
|---|---|---|
| GET/POST | /food/search |
Food search form / results (USDA + cache + recipes) |
| POST | /search |
Legacy alias for POST /food/search |
| GET | /food/search-api-results |
Background/async search results partial |
| GET | /food/analyze-portion-api-results |
Background/async portion-analysis results partial |
| POST | /food/confirm-aa |
Batch-fetch full nutrient detail for checked "~✓" search results (see search.html below) |
| GET/POST | /food/analyze-portion |
Portion analysis form / results |
| GET/POST | /food/analyze-recipe-portion |
Recipe portion analysis form / results |
| GET | /food/convert |
Portion conversion search |
| GET | /food/convert/{fdc_id} |
Portion conversion detail for a specific food |
| GET | /food/convert/recipe/{recipe_id} |
Portion conversion detail for a recipe |
| GET | /food/compare |
Food comparison table (query params: ids=, amounts=, search=) |
| GET | /food/compare/export.csv |
Export the current comparison as CSV |
| POST | /food/compare/add, add-multiple, remove, cache-food, amounts |
Manage the current comparison list |
| POST | /food/compare/save |
Save the current comparison list |
| GET | /food/compare/load/{cmp_id} |
Load a saved comparison |
| POST | /food/compare/saved/rename, saved/delete |
Manage saved comparisons |
| GET | /food/cache |
Browse/search cached foods (show_archived supported) |
| GET | /food/cache/export.csv |
Export the food cache as CSV |
| POST | /food/cache/delete |
Remove a food from the cache |
| POST | /food/cache/{fdc_id}/archive |
Archive/restore a cached food |
| POST | /food/cache/claude-fetch |
Build a Claude AI fetch prompt for a food |
| GET/POST | /food/cache/claude-import |
Import a pasted Claude AI response |
| GET/POST | /food/cache/import-csv |
Import foods from a CSV file |
| GET/POST | /food/cache/prune |
Preview / delete foods unused by pantry/recipes/meals (user-drafted foods protected) |
| GET/POST | /food/cache/db-check, /food/cache/db-check/repair |
Database integrity check and repair |
| GET | /food/cache/{fdc_id}/portions |
Manage a cached food's named portions |
| POST | /food/cache/{fdc_id}/portions/add, portions/delete |
Add/delete a named portion |
| POST | /food/cache/{fdc_id}/refresh |
Re-fetch a food's nutrients from its source API |
| GET | /food/custom-profiles |
List user-drafted food profiles |
| POST | /food/custom-profiles/create |
Create a new drafted profile |
| POST | /food/custom-profiles/delete/{fdc_id} |
Delete a drafted profile |
| GET/POST | /food/custom-profiles/{fdc_id}/edit |
Edit a food's nutrients (drafted profiles and Food Cache share this) |
| POST | /food/custom-profiles/{fdc_id}/copy-aa |
Estimate AA profile by scaling from another food (aa_estimate.py) |
| POST | /food/custom-profiles/{fdc_id}/copy-nutrients |
Copy a whole nutrient profile from another food |
| POST | /food/custom-profiles/copy/{fdc_id}, copy-from-search |
Start a new drafted profile pre-filled from an existing food |
| GET | /food/annotate |
Browse foods for GI/DIAAS annotation |
| GET/POST | /food/annotate/{fdc_id} |
Edit / save an annotation |
| POST | /food/annotate/{fdc_id}/skip-forever, clear |
Suppress future prompts / clear an annotation |
| GET | /food/{fdc_id} |
Food detail with nutrient table and protein quality (registered last among /food/*) |
| GET | /food/{fdc_id}/print |
Printable food detail page |
| Method | Path | Description |
|---|---|---|
| GET | /pantry |
Pantry list (show_archived supported) |
| POST | /pantry/add |
Add a food to pantry |
| POST | /pantry/remove/{pantry_id} |
Remove a food from pantry |
| POST | /pantry/{pantry_id}/archive |
Archive/restore a pantry entry |
| Method | Path | Description |
|---|---|---|
| GET | /meals |
Meal list (query params: show_all=, date=YYYY-MM-DD) |
| POST | /meals/compute-bcp |
Recompute DCP for all complete recipes referenced across meals |
| POST | /meals/create |
Create a new meal |
| POST | /meals/delete-day |
Delete every meal on a given date |
| GET | /meals/search |
Search meal history by food name (query param: q=) |
| GET | /meal/{meal_id} |
Meal view/edit (query param: q= for food search) |
| GET | /meal/{meal_id}/print |
Printable meal page |
| GET | /meal/{meal_id}/search-api-results |
Background/async add-food search results partial |
| POST | /meal/{meal_id}/confirm-aa, refresh-aa |
Batch-fetch/refresh AA data for a meal's items |
| POST | /meal/{meal_id}/add, add-recipe |
Add a food / recipe to a meal |
| POST | /meal/{meal_id}/remove/{item_id} |
Remove an item from a meal |
| POST | /meal/{meal_id}/update/{item_id} |
Edit an item's amount and notes |
| POST | /meal/{meal_id}/rename |
Rename a meal |
| POST | /meal/{meal_id}/complete |
Toggle meal complete/incomplete |
| POST | /meal/{meal_id}/delete |
Delete a meal |
| POST | /meal/{meal_id}/merge |
Merge selected meals on the same date into one |
| GET | /meal/{meal_id}/day |
Full-day analysis for all meals on the same date as this meal |
| GET | /meal/{meal_id}/day/print |
Printable full-day analysis |
| POST | /meal/{meal_id}/day/profile |
Set/override the profile pinned to that day |
| Method | Path | Description |
|---|---|---|
| POST | /recipes/compute-bcp |
Recompute DCP for every complete recipe |
| GET | /recipes |
Recipe browse/search list |
| GET | /recipes/broken-refs |
List recipes with broken ingredient references |
| GET/POST | /recipe/import-csv |
Import a recipes.csv + foods.csv bundle |
| GET/POST | /recipe/new |
Create a new recipe |
| GET | /recipe/compare |
Recipe comparison table |
| POST | /recipe/compare/add, add-multiple, remove, save |
Manage the current recipe comparison list |
| GET | /recipe/compare/load/{cmp_id} |
Load a saved recipe comparison |
| POST | /recipe/compare/saved/rename, saved/delete |
Manage saved recipe comparisons |
| GET | /recipe/{recipe_id} |
Recipe detail (ingredients, DCP, protein quality) |
| GET | /recipe/{recipe_id}/export.csv |
Export one recipe as CSV |
| GET | /recipe/{recipe_id}/print |
Printable recipe page |
| GET/POST | /recipe/{recipe_id}/edit |
Recipe edit form / save |
| POST | /recipe/{recipe_id}/relink |
Relink a broken ingredient reference to a different food |
| POST | /recipe/{recipe_id}/delete, archive, copy |
Delete / archive-restore / duplicate a recipe |
| POST | /recipe/{recipe_id}/confirm-aa |
Batch-fetch AA data for the recipe's ingredients |
| POST | /recipe/{recipe_id}/ingredient/add, add-recipe |
Add a food / sub-recipe ingredient |
| POST | /recipe/{recipe_id}/ingredient/{ing_id}/remove, edit, move |
Manage one ingredient row |
| POST | /recipe/{recipe_id}/instructions, introduction |
Save the recipe's instructions / intro text |
| Method | Path | Description |
|---|---|---|
| GET | /summary |
Daily summary landing page (Recent Days list) |
| GET | /summary/{meal_date} |
Daily summary for one date |
| POST | /summary/{meal_date}/profile |
Set/override the profile pinned to that date |
| GET | /summary/trend |
Multiday nutrient trend view |
| GET | /summary/nutrient-plot, nutrient-plot/image, nutrient-plot/print |
Nutrient trend line-chart picker, rendered image, and print view |
| GET | /analysis/food-use |
Frequency of a food's use across meals/date ranges |
| POST | /analysis/food-use/substitute |
Substitute one food for another across matched meal items |
| GET | /analysis/food-use-recipes |
Frequency of a food's use across recipes |
| POST | /analysis/food-use-recipes/substitute |
Substitute one food for another across matched recipe ingredients |
| Method | Path | Description |
|---|---|---|
| GET/POST | /settings |
Settings page (profile, diet, API key, DIAAS overrides, RDA table) / save profile |
| POST | /settings/diet |
Save dietary preference |
| POST | /settings/api-key |
Save USDA API key |
| POST | /settings/search-boost |
Save the Foundation/SR-Legacy search-boost page size |
| POST | /settings/diaas-override, diaas-override/delete |
Add/update or delete a DIAAS digestibility override |
| POST | /settings/nutrient-target, nutrient-target/load-defaults |
Edit / reset Profile Optimal targets and max limits |
| POST | /settings/starter-data/load, clear, restore |
Load / clear / selectively restore demo starter data |
| POST | /settings/meal-nutrients |
Save the Meals & Log custom-column selection |
| POST | /settings/recompute-error/{error_id}/resolve |
Retry a failed DCP recompute (Settings > System Issues) |
| POST | /recompute-errors/ack-banner |
Dismiss the home-page recompute-error banner |
| GET | /manual |
Rendered user-manual.md |
| GET | / |
Home page |
Shared layout wrapper. Includes the vendored Bootstrap 5 CSS/JS (web/static/vendor/bootstrap/ — not a CDN, so the app works offline), /static/style.css, the top navbar with dropdown menus, a footer, and the keyboard-shortcut JS. All other templates extend this.
The navbar marks the active section by comparing request.url.path to each nav link's prefix. Foods, Recipes, and Analysis (Daily summary, Food use in meals/recipes) are dropdowns; Meals, Settings, and Manual are top-level links.
Unsaved-changes warning. A third inline script in base.html generically tracks every form[method="post"] containing at least one non-hidden editable field: it snapshots the form's serialized state (FormData → URLSearchParams) on load, re-checks on input/change, and toggles a .form-dirty class on the form plus .btn-dirty on its submit button (CSS in web/static/style.css) and a JS-injected .unsaved-badge ("Unsaved changes") span. A beforeunload listener warns if any tracked form is still dirty. Forms with no editable fields (delete/move/mark-complete one-click actions) and GET forms (search/filter) are excluded automatically by the selector, so no per-template opt-out markup is needed.
Search-state restore. A fourth inline script opts a page into remembering its last search across a brief trip to another page (via a plain link — the browser's own Back button already preserves URL state, so this only fills the gap for forward navigation). A page marks its search <form> with the bare data-persist-search attribute (currently meal.html's "Add Food or Recipe" search and recipe_edit.html's "Add Ingredient" search). On every load of a marked page the script: saves the current ?q= to sessionStorage under numa_search_q:<pathname> if present and non-empty; clears it if q is present but explicitly empty (a deliberate cleared search must not be overridden); and if q is absent entirely, redirects once to re-add the last saved q (restoring results) when a saved value exists. Any element marked data-reset-search (rendered only when a search is active, e.g. a "Clear search" link) clears the saved value and reloads the bare path — the page's clean default state. Purely client-side; no server route or session state involved.
Select-all-sources. A fifth inline script gives every Source filter row (_source_filter_select.html) a "Select all sources" button (data-select-all-sources), which re-checks every checkbox inside its enclosing role="group" container without touching search text, sort, or the result-limit input — unlike "Clear search", which resets all of those. Delegated on document, so it works on every page that includes the Source filter row, including ones with no data-persist-search form.
Renders the content of home.md (project root) as HTML. The markdown file is rendered once at startup and cached in web/home_body.cache; the cache is invalidated if home.md is newer.
Status lines below the Welcome heading. Three lines, <br>-separated inside one <p class="muted mb-0"><small> block: dietary preference, active profile, and Current version date: {{ version_date }} — version_date is index()'s full VERSION string (web/backend.py), i.e. the yyyy-mm-dd:hhmm stamp from version.py, not just its date portion. Whenever NEW_VERSION_NOTE (version.py) is non-empty it's appended in parentheses on that same line as (Version note: {{ version_note }}). This is the only place the build note shows when no update is available — there is no separate standalone box for it (there used to be; removed since it duplicated the same information already on this line).
Update-check flow. index() calls _update_check.check_for_update(VERSION) (numa_app/services/update_check.py) on every load of / — launch, manual reload, or navigating back to Home from elsewhere — except immediately after a successful in-place update (updated=1 query param), since the running process's in-memory VERSION is still stale until relaunch. check_for_update() compares the tag-ified current version against GitHub's latest-release tag via plain string comparison (safe because VERSION is a fixed-width, lexically-sortable stamp) and returns {"tag": ..., "url": ...} or None; it never raises, so a network failure or offline state is silently treated as "no update." Its own module-level cache (_CACHE_TTL_SECONDS = 6 * 60 * 60) means repeated home-page loads within a 6-hour window reuse the last GitHub response rather than making a fresh call each time; the cache key includes the version string, so it self-invalidates the moment VERSION changes (e.g. right after a self-update).
Notification-frequency gate. Even when check_for_update() reports a real update, index() only lets the UPDATE AVAILABLE banner through if _should_show_update_notice() (web/backend.py) says so — otherwise it sets update_available = None before rendering, same as the post-update suppression case. That helper reads the saved update_notify_frequency pref ("daily" / "weekly" / "monthly", default "daily", validated against _VALID_UPDATE_NOTIFY_FREQS) and an update_notice_last_shown_at ISO date, both in prefs.json; if fewer days than the frequency's interval (_UPDATE_NOTIFY_FREQ_DAYS: 1/7/30) have elapsed since the last time the banner was shown, it returns False without touching prefs.json. Otherwise it stamps today's date into update_notice_last_shown_at and returns True. This is a display throttle only — it never affects the 6-hour network-cache TTL above, which keeps polling GitHub at its own fixed cadence regardless of the user's notification preference. The setting itself lives in Settings → "Update Notifications" (settings.html, POST /settings/update-notify-frequency), following the same radio-button/prefs.json pattern as the Dietary Preferences section (diet_pref).
When the banner does show, its build-note line also names the current frequency setting in a muted aside with a link to /settings#update-notifications, so the note doubles as a reminder of how often you've asked to be told.
Why a freshly-published release can go undetected for a while, and how to force a recheck. _cache/_cache_checked_at (update_check.py) are plain module-level globals — a per-process cache, not a per-page-load or per-user one. If the running server process already checked GitHub (and got "no update") before a new release went live, that None answer sticks around in memory for up to 6 hours regardless of how many times the home page is reloaded in the meantime — reloading the page re-runs check_for_update(), but the function just returns its cached answer without making a new network call. The fix is to restart the process, since a brand-new process starts with an empty cache and checks GitHub fresh on its very first home-page load. Two ways to do that:
- Just relaunch NutriMagnus (click its icon/shortcut again, or re-run the packaged binary).
web/launcher.py'smain()already detects that its port is occupied and kills the old process for you before starting a new one (fuser -k {port}/tcpon Linux/Mac,taskkillvianetstaton Windows) — this is the same port-conflict handling that lets you just launch NuMa again without manually stopping anything first, and it happens to also be the simplest way to force a fresh update check. - Kill it manually first, if you'd rather: the packaged Linux binary runs as a process named
nutrimagnus(pkill nutrimagnus, orpkill -f nutrimagnusif that doesn't match); running from source, it'spkill -f "web/launcher.py"(orCtrl+Cin the terminal it's running in). Then relaunch normally.
Either way, waiting up to 6 hours without restarting works too — the cache expires on its own — but restarting is instant.
Food search results. Shows a results table with food name, data type, brand, and source badge (pantry / cache / recipe / usda / off). Each row links to /food/{fdc_id}. Used for both the Foods → Search page and as a reusable search partial.
Ranking. Results can be ordered two ways, chosen via a dropdown and persisted per-user in prefs.json (key sort_food_search, default "relevance"; shared with the meal add-food panel in meal.html): "relevance" ranks purely by name-match quality (_search_relevance_key() in web/backend.py — exact match, then prefix match, then fraction of query words matched, then shortest name, then alphabetical), ignoring source; "grouped" sorts by source category first (_SEARCH_CATEGORY_RANK: pantry → recipe → cache → USDA/OFF), using the same relevance key as a tiebreaker within each group. Both modes are applied via the shared _sort_search_results() helper. A cached food is tagged "pantry" instead of "cache" when its fdc_id is present in the Pantry table (_pantry_fdc_ids()).
Batch AA confirmation. A live (uncached) USDA search result's aa field is the coarse guess "~✓" for Foundation/SR Legacy types — search_foods()'s API response carries no nutrient data at all, so completeness genuinely can't be known without a detail fetch, and fetching every uncached result on every search would cost one USDA API call each. Instead, search.html renders a checkbox per "~✓" row (plus a select-all checkbox in the header) inside a <form action="/food/confirm-aa">; a "Fetch full details for selected" submit button (disabled until at least one box is checked, via inline JS) posts the chosen fdc_ids. POST /food/confirm-aa (web/backend.py) fetches and caches each one not already cached (same get_food_detail() + cache_food() pattern as meal_add_food), then redirects back to /food/search?query=...&sort=... — where those foods now resolve to a confirmed "✓" or "✗" via the normal cached-food path in _search_logic().
Displays full nutrient data for one food, scaled to the requested gram amount (?amount=N). Shows:
- Food name, data type, brand, serving size
- Portion picker (USDA named portions, if any)
- Nutrient table grouped by Macronutrients, Omega Fatty Acids, Minerals, Vitamins, Phytonutrients, Amino Acids — each row with value, unit, and RDA % (colour-coded if profile exists)
- Protein quality section (DIAAS score + AA ratio table) if amino acid data is present
- Antinutrient flags if applicable
Two-phase page: search form → results table → pick a food → enter gram amount → full nutrient table. Reuses the same search logic as search.html.
Dropdown of all saved recipes + servings input → scaled nutrient table. Shows protein quality section if recipe has AA data.
Portion ↔ weight conversion. Phase 1: search for a food. Phase 2: shows the food's USDA named portions, gram weights, and the computed density (g/mL) for volume conversion. Highlights the closest USDA portion to an entered gram amount.
Side-by-side nutrient comparison. Up to 6 foods; amounts are independently adjustable in grams. Highest value per nutrient row is highlighted. Saved comparison lists can be named, saved, loaded, and deleted. Food search is inline on the same page.
Browsable/searchable table of all cached foods. Columns: FDC ID, name, data type, brand, AA data flag, GI annotation, DIAAS, notes. The DIAAS column shows your saved annotation (marked ★) when one exists, otherwise the keyword-matched reference-table value (see "Per-food DIAAS via annotations" above) for foods with amino acid data — blank otherwise. Each row links to /food/{fdc_id} and /food/annotate/{fdc_id}. Supports deletion, archive/restore, CSV export, and links to prune/import-CSV/db-check sub-pages.
Lists all user-drafted food profiles (data type = "User Drafted"). Provides a create-by-name form and per-row delete. Creating a profile immediately redirects to /food/{fdc_id} for nutrient editing.
Two-mode template. In list mode: browsable/searchable table of cached foods showing existing GI and DIAAS annotations. In edit mode (editing=True): form for entering GI estimate (0–100), DIAAS digestibility (0–1), and prep context note, with "no prompt" checkboxes to suppress future annotation prompts.
Table of pantry items. Columns match food_cache.html: food name, FDC ID link (if available), data type, AA data flag, GI annotation, DIAAS (same saved-annotation-or-reference-table logic, ★ marks a saved value), notes, added date. Add-by-name form at top. Per-row remove button.
Meal list with:
- New Meal form (name + date)
- Date filter form (
?date=YYYY-MM-DD) + "Search meal history" link - Table columns: Meal name (links to
/meal/{id}), Date, Done (✓ / ·), Items count - Show-all / show-recent-9 toggle when more than 9 meals exist
Full meal view and edit page. Sections (all collapsible with <details>):
- Header: meal name, complete/incomplete badge, date
- Management bar: Mark complete/incomplete toggle, Analyze full day button (shown when other meals exist on the same date), Rename (inline collapsible form), Delete (with JS confirmation)
- Add Food or Recipe: food search form + results table; foods add by gram weight, recipes add by serving count
- Items: table with food name link, amount, notes, per-item Edit (inline collapsible form for amount + notes) and Remove buttons
- Merge meals: shown when other meals exist on the same date; checkboxes to select which meals to merge, name input, delete-originals option
- Protein Quality (DIAAS): meal-level DIAAS score, total protein, digestible complete protein, limiting amino acid, per-AA ratio table. When DCP is capped (see DCP cap), the derivation line shows the uncapped projection and the capped result instead of a plain
raw protein × DIAASequation (dcp_was_capped, via_build_diaas_display()inweb/backend.py). - Total Nutrients: grouped nutrient table with RDA % colour-coded by target type
Full-day analysis for all meals on a single date. Reached via the "Analyze full day" button on meal.html. Shows:
- List of all meals on the date (with links back to each meal)
- Combined DIAAS (pooled across all meals)
- Combined nutrient table with RDA % (using full-day totals as the denominator)
Meal history search. Query param q= searches food names across all logged meal items (recipes are matched by name; ingredients inside recipes are not searched). Shows:
- All Occurrences table: date, meal (link), food name, portion, notes
- Summary by Food table: unique food names with times used, total grams consumed, first/last date seen
Recipe browse/search list. Fully implemented — no longer a stub. Recent recipes (via recipe_list_recent()), complete/incomplete status, a link to recipes/broken-refs, and links into recipe_new.html, recipe_compare.html, and recipe_import_csv.html.
Form to create a new recipe (name, description, servings, total weight/volume).
Recipe view page: ingredients (with sub-recipe expansion), per-serving DCP, protein quality (DIAAS) section, print and CSV-export links, and workflow actions (edit, delete, archive, copy, relink broken references).
Recipe edit page: add/remove/reorder ingredients (food or sub-recipe), inline ingredient edit, instructions/introduction text, servings, and total weight/volume. The "Add Ingredient" search form uses the same data-persist-search mechanism as meal.html.
Side-by-side recipe comparison, mirroring food_compare.html: multiple recipes, save/load named comparison lists.
Import a recipes.csv + foods.csv bundle (numa_app/services/recipe_csv.py), including sub-recipes.
Lists recipes whose ingredient references point at a deleted/missing food or recipe; offers relink and substitute actions.
food_cache_prune.html, food_cache_portions.html, food_cache_import_csv.html, food_cache_db_check.html
Food Cache sub-pages: preview/confirm pruning of unreferenced cached foods; manage a food's named portions; import foods from CSV; and a database integrity check with a repair action.
Nutrient-editing form shared by Food Cache edits and Drafted Food Profiles edits (see "Editing rule" above) — macros, minerals, vitamins, phytonutrients, and amino acids, plus the AA copy/estimate picker described under aa_estimate.py.
Build a Claude AI prompt requesting a food's amino-acid/nutrient profile, then paste and import Claude's response into the food cache (numa_app/services/claude_fetch.py).
Analysis pages under the Analysis dropdown: frequency of a given food's use across logged meals (with date-range filters) and across recipes, respectively, each with a substitute-this-food-everywhere action.
Daily summary landing page — no longer a stub. Lists Recent Days (via the day-analysis machinery shared with meal_day.html) and links into a specific date's summary (/summary/{meal_date}), the multiday trend view, and the nutrient plot.
Multiday nutrient trend view: averages a chosen set of nutrients across a date range (numa_app/services/nutrient_trend.py).
Line-chart view of a chosen nutrient's day-by-day totals over a date range (numa_app/services/plotting.py), and a print-formatted variant.
Shared printable-page template used by the various .../print routes (food, meal, day, recipe, nutrient plot) — driven by numa_app/services/print_sections.py's "what to include" checkbox vocabulary.
Several forms on one page:
- User profile: age, sex, weight (kg or lb), height (cm or ft+in), activity level → computes and displays the RDA table
- Dietary preferences: radio buttons (all animal foods / vegetarian / plant-based only)
- USDA API key: plain-text input (shows the current key directly when one is saved), plus the Foundation/SR-Legacy search-boost page-size control
- DIAAS digestibility overrides: table of existing overrides with delete buttons; add-new form (food name, digestibility 0–1, optional notes)
- Nutrient Targets: Profile Optimal targets and custom max limits, per nutrient
- Starter data: load/clear/selectively-restore the bundled demo data
- System Issues: unresolved
recompute_errorsentries with a Retry action
Renders user-manual.md as HTML using the Python markdown library with toc, fenced_code, and tables extensions. Provides a scrollable, linked view of the full user manual.
| Location | Contents |
|---|---|
~/.local/share/numa/numa.db |
SQLite database (foods cache, recipes, meals, pantry, DIAAS overrides) |
~/.config/numa/config.json |
USDA API key, search result depth (search_boost_page_size) |
~/.config/numa/theme |
Saved color theme preference |
~/.local/share/numa/prefs.json |
Dietary preferences (diet_pref), editor command, and remembered list-sort choices (sort_recipes, sort_food_cache, sort_meals) |
~/.config/numa/profile.json |
User profile (age, sex, weight, height, activity level) |
~/.numa/reports/ |
Auto-saved nutrition reports (Markdown) — one file per analysis |
~/.numa/user-requested-nutrition-reports/ |
User-exported reports (txt, md, or html) |
733 tests, all passing.
Run with: pytest (uses pytest.ini which sets testpaths = tests and pythonpath = .).
Note:
pytestandhttpxare dev/test-only dependencies, listed inrequirements.txtalongside the runtime ones.httpxis needed fortests/test_web.py(FastAPI'sTestClientrequires it).
| File | What it tests |
|---|---|
tests/conftest.py |
Shared fixtures and sample data constants |
tests/test_db.py |
Schema creation, all CRUD helpers, cascade deletes, rollback on exception |
tests/test_usda.py |
scale_nutrients, sum_nutrients, _parse_food, protein_completeness, nutrient_label, get_diaas, get_antinutrient_flags, suggest_complements, get_density_g_per_ml |
tests/test_diaas.py |
get_digestibility (all tiers), meal_level_diaas (edge cases, complementarity, pairing, gap flags), DIAAS override CRUD |
tests/test_diaas_properties.py |
Property-based tests for diaas.py's meal_level_diaas()/get_digestibility(), complementing test_diaas.py's known-combination checks |
tests/test_profile.py |
load_profile, save_profile, bmr, compute_rda (sex/age/activity variants), compute_optimal, get_max_limits, unit conversion helpers |
tests/test_web.py |
FastAPI TestClient tests: every parameter-free page render, food search/detail, and all mutating POST workflows — pantry, meals (create/add/complete/delete/rename/merge/refresh-aa/add-recipe), recipes (new/edit/delete/copy/ingredient add-edit-move), custom profiles, settings (profile/DIAAS-override), food-cache delete/prune, annotate, and compare (add/add-multiple/remove/amounts/save/load/rename/delete) |
tests/test_complements.py |
numa_app/services/complements.py: aa_effects() digestibility rescaling, two_step_combo(), build_complement_display() gap detection |
tests/test_recipe_nutrients.py |
numa_app/services/recipe_nutrients.py: nested sub-recipe expansion/flattening, linear portion scaling, best_aa_nutrients() complement fallback |
tests/test_recipe_csv.py |
numa_app/services/recipe_csv.py: recipe CSV export/import, sub-recipe closure collection, two-pass dedup-and-create import |
tests/test_glycemic_load.py |
numa_app/services/glycemic_load.py: food/recipe line items, recipe GL rollup via gl_g, partial totals alongside blockers |
tests/test_meal_bcp.py |
numa_app/services/meal_bcp.py: recipe_dcp_fallback() sums precomputed recipe dcp_g when ingredient-level AA data is unavailable |
tests/test_rda_status.py |
numa_app/services/rda_status.py: rda_status() tier boundaries for minimum/target and limit-type nutrients; limit_warning() 90%/100% thresholds |
tests/test_food_import.py |
numa_app/services/food_import.py: VALID_NUTRIENT_KEYS completeness, convert_per_serving() scaling/validation, validate_and_strip() key/type filtering |
tests/test_food_cascade.py |
The food-edit → recipe DCP cascade (recipe_dcp.cascade_food_change) and the recompute_errors log it feeds on a failed cascade step |
tests/test_aa_estimate.py |
numa_app/services/aa_estimate.py: estimate_aa() scaling a food's AA profile from another food's, hand-picked cases |
tests/test_estimate_aa_properties.py |
Property-based tests for estimate_aa(), complementing test_aa_estimate.py |
tests/test_portions.py |
numa_app/services/portions.py: _ing_amount_display() and related portion-string formatting |
tests/test_day_profile.py |
numa_app/services/day_profile.py: per-day profile pinning, backfill, and override behavior |
tests/test_diet_aware.py |
numa_app/services/diet_aware.py: diet-preference-aware RDA-comparison notes (vegetarian/plant-based) |
tests/test_nutrient_trend.py |
numa_app/services/nutrient_trend.py: multi-day nutrient averaging for the N-day trend view |
tests/test_csv_import.py |
numa_app/services/csv_import.py: Food Cache CSV import parsing |
tests/test_claude_fetch.py |
numa_app/services/claude_fetch.py: prompt-building and response-parsing for the Claude AI fetch/import workflow |
tests/test_demo_data.py |
numa_app/services/demo_data.py: load/clear starter foods/pantry/recipes, fresh-install auto-seeding, idempotency, and that real data is never touched |
tests/test_export_starter_data.py |
scripts/export_starter_data.py: starred-recipe/sub-recipe export behavior for regenerating starter_data.json |
tests/test_refresh_starter_data.py |
scripts/refresh_starter_data.py: refreshing existing starter_data.json entries from the live cache by stable ID rather than by name |
tests/test_cnf.py |
cnf_api.py: id assignment, local name-search over a mocked food list, nutrient mapping (network calls mocked) |
tests/test_cofid.py |
cofid_lookup.py: id assignment and local name-search/lookup over a fixture food list (no live API) |
tests/test_afcd.py |
afcd_lookup.py: id assignment and local name-search/lookup over a fixture food list (no live API) |
tests/test_ciqual.py |
ciqual_lookup.py: id assignment and local name-search/lookup over a fixture food list (no live API) |
Autouse fixtures keep each test hermetic:
| Fixture | Effect |
|---|---|
use_test_db |
Redirects _db._DB_PATH to a per-test temp file; schema initialized fresh |
use_test_profile |
Redirects profile._PROFILE_FILE to a per-test temp path |
no_off |
Stubs openfoodfacts.search_foods to return []; prevents network hits and stops OFF results from affecting search ordering or output in any test |
tests/test_web.py additionally has its own use_test_web_prefs fixture (redirects web/backend.py's own _PREFS_FILE constant) and a client fixture (FastAPI TestClient).
A recurring maintenance pass, scoped to what changed since the last sweep — not a full re-audit each time. Run the items in this order — it's not arbitrary: pruning has to happen before the items that scan the changelog (so they're not reading entries about to be deleted), and the link check has to happen last (so it catches anything the manual-editing items introduce).
- CLAUDE.md drift — the package-layout listing near the top of
CLAUDE.mdis hand-maintained; check it against what's actually innuma_app/and the repo root, since new modules added during the week won't show up unless someone remembers to add them. Fast and independent — do it first for an easy win. - Vendored dependency check —
web/static/vendor/bootstrap/(CSS + JS, currently 5.3.8) is vendored locally rather than loaded from a CDN, so the app works offline. Low urgency since this is a locally-run app with no untrusted remote input reaching it, but worth a quick check for newer Bootstrap releases/patches at this cadence rather than a separate one. Also fast and independent. - Changelog pruning — the "Recent program updates log" lives in
user-manual.mdAppendix A (moved here from Appendix K on 2026-08-05 since it's checked far more often than the other appendices). Keep roughly the last 2 weeks of entries. Older entries are safe to delete:create_release.pycopies same-day entries into the release notes at push time and never re-reads the file afterward, so a pruned old entry can't retroactively change a past release's notes (stated in the manual's own[//]: #comment above the log). Do this before items 4-6 below — they all scan "the last two weeks" of this same log, so pruning first means less to read and no risk of auditing an entry that's about to be deleted anyway. - Manual consolidation — the same mechanism explained more than once (once per page/interface) that should live once in Part 4 — Shared Operations (or an existing Part 3 reference section) of
user-manual.md, cross-linked from every place it applies; a feature documented for only one interface/page despite applying to more than one; two command/column lists for the same menu that have drifted out of sync (fix by pointing the thinner one at the canonical list, not updating both); a real behavior change that only exists in the changelog and was never written into the manual body; an Appendix A entry that contradicts a later entry (e.g. a feature marked "not built yet" when a subsequent same-day or later entry announces it shipped). Also worth a dedicated pass: leftover CLI-era content (typed single-letter commands likea{id}=analyze,Type ?keywordhelp references) that survived past the 2026-08-04 CLI removal — grepuser-manual.mdforType ?,^Commands:, andCommand line:as a quick way to surface it; the first full sweep (2026-08-17) found a meaningful amount still there, so don't assume one pass caught it all. - README.md accuracy — repo-root
README.md(the public-facing overview: disclaimer, who it's for, key features list, download section) checked against current app behavior — a feature listed there that changed or was removed, or a new user-facing feature that should be added to the "Key features" list. Distinct from this file (README-numa-documentation.md), which is the internal architecture doc. - Test coverage gaps — cross-check the week's changelog entries against
tests/to catch a shipped behavior change that never got a test. - Stale internal links — every
#anchorreference inuser-manual.mdchecked against actual anchor definitions ([name]tags / heading IDs); also worth a pass over external URLs (footnotes, source citations) for rot. A quick way to check: extract every](#anchor)reference and every{: #anchor}definition and diff them (a Python one-liner with twore.findallcalls does it); for external URLs,curl -s -o /dev/null -w "%{http_code}"with a browser-like-Auser agent and-Lto follow redirects, but treat a 403/429 as inconclusive (bot-blocking, not necessarily rot) and only trust a 404/redirect-to-an-error-page as real rot. Do this last — item 4 (manual consolidation) is the item most likely to add new[text](#anchor)links, so checking beforehand just means checking again afterward anyway.
Items 4-6 all scan the same two-week changelog window for gaps, just against three different targets (manual body, README, test suite) — do a single read-through of the changelog and produce three gap-lists from it, rather than re-reading the same entries three separate times (the first full sweep, 2026-08-17, ran two separate audits that each re-read the same window from scratch).
After a sweep: log the result as a new user-manual.md Appendix A entry (or a suitable per-item entry if the fixes span categories), and bump the manual's own timestamp header. Pure doc/config consolidation does not require a version.py bump (no application behavior changed) — but a real bug fix found via item 6 (missing test written, bug fixed) does.
This file (README-numa-documentation.md) doesn't get checked for accuracy by the weekly sweep above — it's the internal architecture doc, long and slow-changing enough that a weekly check isn't warranted, but it still drifts. Fold this into whichever weekly sweep falls first in a new month (check the "Last monthly accuracy check" date in the header above — if it's not in the current month, this is due):
- Project Structure — the full file/module tree, not just
CLAUDE.md's shorter hand-maintained copy (item 1 of the weekly sweep already covers that one); check every listed file still exists and every one-line description still matches what the file does. - Architecture / Web Interface / Data Storage sections — re-read against the actual current routes, templates, and schema for anything that's quietly gone stale (a renamed route, a removed template, a schema change).
- Setup / Running the Program — commands and paths still correct (venv setup,
python web/launcher.py, requirements). - Test Suite section — counts and fixture descriptions still match
tests/.
Also do a full user-manual.md audit this same week — check the "Last full audit" date in the manual's own header (right below the "Updated" line); if it's not in the current month, this is due. Weekly sweep item 4 (Manual consolidation) only checks what changed since the last sweep — real but gradual drift (a section that was always subtly wrong, or drifted slowly across many small edits with no single triggering changelog entry) can slip past that every week and never get caught. A full audit reads every Part and Appendix against actual current app behavior, the same way item 4 does for a two-week window, just for the whole document. This subsumes weekly item 4 for that week — skip item 4 separately, since the full audit already covers everything it would have found. When done, update the manual's "Last full audit" line to today's date, same rule as the accuracy-check date above: update it every time the audit runs, even if nothing needed fixing.
When done, update the "Last monthly accuracy check" line in this file's header to today's date, regardless of whether anything needed fixing — that line is what tells the next sweep whether this check is due, so it must be updated every time the check runs, not just when it finds something.
For a full explanation of the FAO reference values, EAA ratios, what "complete" protein means, and how the DIAAS score is calculated with worked examples, see Appendix B of the NutriMagnus User Manual.