From d0b7b003f984c227aaf825d7e19cf8fed83a58f4 Mon Sep 17 00:00:00 2001 From: Anthony Merlo Date: Fri, 21 Aug 2026 11:36:14 +0100 Subject: [PATCH] feat: fallback nutrient estimation for potassium/calcium/magnesium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OFF data is sparse for fresh produce — spinach, oats, most frozen fruit have no potassium. Added a curated fallback table (114 foods, USDA-sourced) that injects estimated K/Ca/Mg at log time when OFF has no data. How it works: - data/fallback_nutrients.json: 114 foods with mg/100g values - FallbackTable: case-insensitive exact + substring name matching - log_food: injects _est keys (potassium_100g_est etc.) when OFF missing - sum_nutriments: tries OFF value first (grams×1000), then _est (already mg) Results for today's smoothie + lunch: - Potassium: 691mg → 2,530mg (47% of 5400mg goal) - Calcium: 14mg → 304mg (30% of 1000mg goal) - Magnesium: 61mg → 330mg (73% of 450mg goal) 38 tests passing (7 new: fallback lookup, est fallback, OFF vs est priority) --- data/fallback_nutrients.json | 116 +++++++++++++++++++++++++++++++++++ src/api.rs | 57 +++++++++++++++++ src/tools.rs | 52 ++++++++++++++-- tests/integration_tests.rs | 61 ++++++++++++++++++ 4 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 data/fallback_nutrients.json diff --git a/data/fallback_nutrients.json b/data/fallback_nutrients.json new file mode 100644 index 0000000..971c170 --- /dev/null +++ b/data/fallback_nutrients.json @@ -0,0 +1,116 @@ +{ + "spinach": { "potassium_100g": 558, "calcium_100g": 99, "magnesium_100g": 79 }, + "banana": { "potassium_100g": 358, "calcium_100g": 5, "magnesium_100g": 27 }, + "oats": { "potassium_100g": 429, "calcium_100g": 54, "magnesium_100g": 177 }, + "oat milk": { "potassium_100g": 180, "calcium_100g": 120, "magnesium_100g": 20 }, + "strawberry": { "potassium_100g": 153, "calcium_100g": 16, "magnesium_100g": 13 }, + "strawberries": { "potassium_100g": 153, "calcium_100g": 16, "magnesium_100g": 13 }, + "blueberry": { "potassium_100g": 77, "calcium_100g": 6, "magnesium_100g": 6 }, + "blueberries": { "potassium_100g": 77, "calcium_100g": 6, "magnesium_100g": 6 }, + "pineapple": { "potassium_100g": 109, "calcium_100g": 13, "magnesium_100g": 12 }, + "cherry": { "potassium_100g": 222, "calcium_100g": 13, "magnesium_100g": 11 }, + "cherries": { "potassium_100g": 222, "calcium_100g": 13, "magnesium_100g": 11 }, + "egg": { "potassium_100g": 138, "calcium_100g": 56, "magnesium_100g": 12 }, + "eggs": { "potassium_100g": 138, "calcium_100g": 56, "magnesium_100g": 12 }, + "cheddar": { "potassium_100g": 76, "calcium_100g": 721, "magnesium_100g": 28 }, + "flatbread": { "potassium_100g": 120, "calcium_100g": 100, "magnesium_100g": 30 }, + "chicken": { "potassium_100g": 256, "calcium_100g": 11, "magnesium_100g": 28 }, + "chicken breast": { "potassium_100g": 256, "calcium_100g": 11, "magnesium_100g": 28 }, + "salmon": { "potassium_100g": 363, "calcium_100g": 9, "magnesium_100g": 30 }, + "avocado": { "potassium_100g": 485, "calcium_100g": 12, "magnesium_100g": 29 }, + "potato": { "potassium_100g": 421, "calcium_100g": 12, "magnesium_100g": 23 }, + "sweet potato": { "potassium_100g": 337, "calcium_100g": 30, "magnesium_100g": 25 }, + "broccoli": { "potassium_100g": 316, "calcium_100g": 47, "magnesium_100g": 21 }, + "yogurt": { "potassium_100g": 155, "calcium_100g": 121, "magnesium_100g": 12 }, + "greek yogurt": { "potassium_100g": 141, "calcium_100g": 115, "magnesium_100g": 11 }, + "rice": { "potassium_100g": 86, "calcium_100g": 10, "magnesium_100g": 12 }, + "brown rice": { "potassium_100g": 86, "calcium_100g": 10, "magnesium_100g": 12 }, + "tomato": { "potassium_100g": 237, "calcium_100g": 10, "magnesium_100g": 11 }, + "tomatoes": { "potassium_100g": 237, "calcium_100g": 10, "magnesium_100g": 11 }, + "mushroom": { "potassium_100g": 318, "calcium_100g": 3, "magnesium_100g": 9 }, + "mushrooms": { "potassium_100g": 318, "calcium_100g": 3, "magnesium_100g": 9 }, + "onion": { "potassium_100g": 146, "calcium_100g": 23, "magnesium_100g": 10 }, + "garlic": { "potassium_100g": 401, "calcium_100g": 181, "magnesium_100g": 25 }, + "carrot": { "potassium_100g": 320, "calcium_100g": 33, "magnesium_100g": 12 }, + "carrots": { "potassium_100g": 320, "calcium_100g": 33, "magnesium_100g": 12 }, + "cucumber": { "potassium_100g": 147, "calcium_100g": 16, "magnesium_100g": 13 }, + "pepper": { "potassium_100g": 211, "calcium_100g": 10, "magnesium_100g": 10 }, + "bell pepper": { "potassium_100g": 211, "calcium_100g": 10, "magnesium_100g": 10 }, + "courgette": { "potassium_100g": 261, "calcium_100g": 16, "magnesium_100g": 18 }, + "zucchini": { "potassium_100g": 261, "calcium_100g": 16, "magnesium_100g": 18 }, + "kale": { "potassium_100g": 491, "calcium_100g": 150, "magnesium_100g": 47 }, + "lettuce": { "potassium_100g": 194, "calcium_100g": 36, "magnesium_100g": 13 }, + "cabbage": { "potassium_100g": 170, "calcium_100g": 40, "magnesium_100g": 12 }, + "cauliflower": { "potassium_100g": 299, "calcium_100g": 22, "magnesium_100g": 15 }, + "peas": { "potassium_100g": 244, "calcium_100g": 25, "magnesium_100g": 33 }, + "green beans": { "potassium_100g": 209, "calcium_100g": 37, "magnesium_100g": 25 }, + "green peas": { "potassium_100g": 244, "calcium_100g": 25, "magnesium_100g": 33 }, + "beans": { "potassium_100g": 405, "calcium_100g": 143, "magnesium_100g": 140 }, + "kidney beans": { "potassium_100g": 405, "calcium_100g": 143, "magnesium_100g": 140 }, + "chickpeas": { "potassium_100g": 291, "calcium_100g": 49, "magnesium_100g": 48 }, + "lentils": { "potassium_100g": 369, "calcium_100g": 35, "magnesium_100g": 47 }, + "almond": { "potassium_100g": 733, "calcium_100g": 269, "magnesium_100g": 270 }, + "almonds": { "potassium_100g": 733, "calcium_100g": 269, "magnesium_100g": 270 }, + "walnut": { "potassium_100g": 441, "calcium_100g": 98, "magnesium_100g": 158 }, + "walnuts": { "potassium_100g": 441, "calcium_100g": 98, "magnesium_100g": 158 }, + "cashew": { "potassium_100g": 660, "calcium_100g": 37, "magnesium_100g": 292 }, + "cashews": { "potassium_100g": 660, "calcium_100g": 37, "magnesium_100g": 292 }, + "peanut": { "potassium_100g": 705, "calcium_100g": 92, "magnesium_100g": 168 }, + "peanuts": { "potassium_100g": 705, "calcium_100g": 92, "magnesium_100g": 168 }, + "peanut butter": { "potassium_100g": 649, "calcium_100g": 49, "magnesium_100g": 154 }, + "beef": { "potassium_100g": 271, "calcium_100g": 6, "magnesium_100g": 19 }, + "pork": { "potassium_100g": 287, "calcium_100g": 5, "magnesium_100g": 22 }, + "lamb": { "potassium_100g": 247, "calcium_100g": 6, "magnesium_100g": 22 }, + "turkey": { "potassium_100g": 239, "calcium_100g": 7, "magnesium_100g": 26 }, + "tuna": { "potassium_100g": 252, "calcium_100g": 4, "magnesium_100g": 27 }, + "cod": { "potassium_100g": 228, "calcium_100g": 6, "magnesium_100g": 24 }, + "prawn": { "potassium_100g": 253, "calcium_100g": 70, "magnesium_100g": 22 }, + "shrimp": { "potassium_100g": 253, "calcium_100g": 70, "magnesium_100g": 22 }, + "milk": { "potassium_100g": 150, "calcium_100g": 120, "magnesium_100g": 11 }, + "whole milk": { "potassium_100g": 150, "calcium_100g": 120, "magnesium_100g": 11 }, + "cheese": { "potassium_100g": 76, "calcium_100g": 721, "magnesium_100g": 28 }, + "mozzarella": { "potassium_100g": 75, "calcium_100g": 505, "magnesium_100g": 20 }, + "feta": { "potassium_100g": 62, "calcium_100g": 493, "magnesium_100g": 10 }, + "bread": { "potassium_100g": 115, "calcium_100g": 114, "magnesium_100g": 25 }, + "whole wheat bread": { "potassium_100g": 220, "calcium_100g": 107, "magnesium_100g": 76 }, + "pasta": { "potassium_100g": 44, "calcium_100g": 7, "magnesium_100g": 18 }, + "noodle": { "potassium_100g": 44, "calcium_100g": 7, "magnesium_100g": 18 }, + "quinoa": { "potassium_100g": 172, "calcium_100g": 47, "magnesium_100g": 197 }, + "buckwheat": { "potassium_100g": 460, "calcium_100g": 18, "magnesium_100g": 231 }, + "orange": { "potassium_100g": 181, "calcium_100g": 40, "magnesium_100g": 10 }, + "apple": { "potassium_100g": 107, "calcium_100g": 6, "magnesium_100g": 5 }, + "pear": { "potassium_100g": 116, "calcium_100g": 9, "magnesium_100g": 7 }, + "grape": { "potassium_100g": 191, "calcium_100g": 10, "magnesium_100g": 7 }, + "grapes": { "potassium_100g": 191, "calcium_100g": 10, "magnesium_100g": 7 }, + "mango": { "potassium_100g": 168, "calcium_100g": 11, "magnesium_100g": 10 }, + "kiwi": { "potassium_100g": 312, "calcium_100g": 34, "magnesium_100g": 17 }, + "watermelon": { "potassium_100g": 112, "calcium_100g": 7, "magnesium_100g": 10 }, + "raspberry": { "potassium_100g": 151, "calcium_100g": 20, "magnesium_100g": 22 }, + "raspberries": { "potassium_100g": 151, "calcium_100g": 20, "magnesium_100g": 22 }, + "blackberry": { "potassium_100g": 162, "calcium_100g": 29, "magnesium_100g": 20 }, + "blackberries": { "potassium_100g": 162, "calcium_100g": 29, "magnesium_100g": 20 }, + "cranberry": { "potassium_100g": 85, "calcium_100g": 8, "magnesium_100g": 6 }, + "pomegranate": { "potassium_100g": 236, "calcium_100g": 10, "magnesium_100g": 12 }, + "fig": { "potassium_100g": 232, "calcium_100g": 35, "magnesium_100g": 17 }, + "date": { "potassium_100g": 656, "calcium_100g": 64, "magnesium_100g": 54 }, + "dates": { "potassium_100g": 656, "calcium_100g": 64, "magnesium_100g": 54 }, + "raisin": { "potassium_100g": 749, "calcium_100g": 50, "magnesium_100g": 32 }, + "raisins": { "potassium_100g": 749, "calcium_100g": 50, "magnesium_100g": 32 }, + "apricot": { "potassium_100g": 259, "calcium_100g": 13, "magnesium_100g": 10 }, + "peach": { "potassium_100g": 190, "calcium_100g": 6, "magnesium_100g": 9 }, + "plum": { "potassium_100g": 157, "calcium_100g": 6, "magnesium_100g": 7 }, + "lemon": { "potassium_100g": 138, "calcium_100g": 26, "magnesium_100g": 8 }, + "lime": { "potassium_100g": 102, "calcium_100g": 33, "magnesium_100g": 6 }, + "coconut": { "potassium_100g": 356, "calcium_100g": 14, "magnesium_100g": 32 }, + "coconut water": { "potassium_100g": 250, "calcium_100g": 24, "magnesium_100g": 25 }, + "dark chocolate": { "potassium_100g": 559, "calcium_100g": 73, "magnesium_100g": 146 }, + "cocoa": { "potassium_100g": 1524, "calcium_100g": 128, "magnesium_100g": 499 }, + "tofu": { "potassium_100g": 121, "calcium_100g": 350, "magnesium_100g": 30 }, + "tempeh": { "potassium_100g": 412, "calcium_100g": 111, "magnesium_100g": 81 }, + "hummus": { "potassium_100g": 228, "calcium_100g": 38, "magnesium_100g": 36 }, + "falafel": { "potassium_100g": 385, "calcium_100g": 54, "magnesium_100g": 46 }, + "olive oil": { "potassium_100g": 1, "calcium_100g": 1, "magnesium_100g": 0 }, + "butter": { "potassium_100g": 24, "calcium_100g": 24, "magnesium_100g": 2 }, + "honey": { "potassium_100g": 52, "calcium_100g": 6, "magnesium_100g": 2 }, + "maple syrup": { "potassium_100g": 204, "calcium_100g": 102, "magnesium_100g": 12 } +} \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 6e6383d..c8dcf4e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,16 +1,67 @@ // OpenFood Facts API client: Search-a-licious + v3 barcode + SQLite caching. +use std::collections::HashMap; + use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::db::DbPool; +/// Fallback nutrient estimates (mg per 100g) for fresh produce where OFF data is sparse. +/// Bundled at compile time from data/fallback_nutrients.json. +#[derive(Debug, Clone, Default)] +pub struct FallbackTable { + foods: HashMap, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct FallbackNutrients { + #[serde(default)] + pub potassium_100g: Option, // mg + #[serde(default)] + pub calcium_100g: Option, // mg + #[serde(default)] + pub magnesium_100g: Option, // mg +} + +impl FallbackTable { + /// Load from the bundled JSON file at compile time. + pub fn load() -> Self { + let json = include_str!("../data/fallback_nutrients.json"); + let foods: HashMap = + serde_json::from_str(json).unwrap_or_default(); + Self { foods } + } + + /// Try to find fallback nutrients for a product name. + /// Does case-insensitive exact match first, then substring match. + pub fn lookup(&self, product_name: &str) -> Option<&FallbackNutrients> { + let name_lower = product_name.to_lowercase(); + // Exact match first + if let Some(n) = self.foods.get(&name_lower) { + return Some(n); + } + // Substring match (e.g. "Leaf spinach" matches "spinach") + // Try longer keys first to prefer more specific matches + let mut best: Option<(&String, &FallbackNutrients)> = None; + for (key, nutrients) in &self.foods { + if name_lower.contains(key.as_str()) { + if best.is_none_or(|(k, _)| k.len() < key.len()) { + best = Some((key, nutrients)); + } + } + } + best.map(|(_, n)| n) + } +} + /// OFF API client for food lookups. #[derive(Clone)] pub struct OffClient { http: reqwest::Client, user_agent: String, + fallback: FallbackTable, } /// A cached product from OFF (stored in SQLite products table). @@ -81,9 +132,15 @@ impl OffClient { Self { http, user_agent: user_agent.to_string(), + fallback: FallbackTable::load(), } } + /// Access the fallback nutrient table (for use at log time). + pub fn fallback(&self) -> &FallbackTable { + &self.fallback + } + /// Search products by name using Search-a-licious. /// Returns up to `page_size` results. /// Note: Search-a-licious doesn't return nutriments in search hits, diff --git a/src/tools.rs b/src/tools.rs index df255bf..a5392e0 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -229,11 +229,25 @@ pub fn sum_nutriments(entries_nutriments: &[Value]) -> Value { let mut sum = 0.0; let mut has_data = false; let is_mg = MG_NUTRIENTS.contains(tracked_key); + let est_key = format!("{}_est", tracked_key); + for entry_nutriments in entries_nutriments { + // Try main key first (OFF value, in grams for mg nutrients) if let Some(val) = entry_nutriments.get(tracked_key).and_then(|v| v.as_f64()) { - // OFF stores potassium/calcium/magnesium in grams; convert to mg - sum += if is_mg { val * 1000.0 } else { val }; - has_data = true; + if val > 0.0 { + sum += if is_mg { val * 1000.0 } else { val }; + has_data = true; + continue; + } + } + // Try fallback estimate (already in mg, no conversion needed) + if is_mg { + if let Some(val) = entry_nutriments.get(&est_key).and_then(|v| v.as_f64()) { + if val > 0.0 { + sum += val; + has_data = true; + } + } } } totals.insert( @@ -416,8 +430,36 @@ impl NutritionServer { 100.0 // default to 100g }; - // Scale nutriments - let nutriments: Value = serde_json::from_str(&product.nutriments).unwrap_or(json!({})); + // Scale nutriments — inject fallback estimates for missing K/Ca/Mg first + let mut nutriments: Value = serde_json::from_str(&product.nutriments).unwrap_or(json!({})); + + // Check if potassium/calcium/magnesium are missing or zero from OFF + let needs_k = nutriments.get("potassium_100g").and_then(|v| v.as_f64()).map_or(true, |v| v <= 0.0); + let needs_ca = nutriments.get("calcium_100g").and_then(|v| v.as_f64()).map_or(true, |v| v <= 0.0); + let needs_mg = nutriments.get("magnesium_100g").and_then(|v| v.as_f64()).map_or(true, |v| v <= 0.0); + + if needs_k || needs_ca || needs_mg { + if let Some(fb) = self.off_client.fallback().lookup(&product.product_name) { + if let Some(obj) = nutriments.as_object_mut() { + if needs_k { + if let Some(k) = fb.potassium_100g { + obj.insert("potassium_100g_est".to_string(), json!(k)); + } + } + if needs_ca { + if let Some(ca) = fb.calcium_100g { + obj.insert("calcium_100g_est".to_string(), json!(ca)); + } + } + if needs_mg { + if let Some(mg) = fb.magnesium_100g { + obj.insert("magnesium_100g_est".to_string(), json!(mg)); + } + } + } + } + } + let scaled = scale_nutriments(&nutriments, grams); // Insert entry diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index d42f6de..e4cb9e8 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -249,6 +249,67 @@ mod tests { assert_eq!(totals["energy_kcal"], json!(200.0)); } + #[test] + fn test_sum_nutriments_uses_est_fallback() { + // Entry has only _est potassium (from fallback table, already in mg) + let entries = vec![json!({"potassium_100g_est": 558.0})]; + let totals = tools::sum_nutriments(&entries); + assert_eq!(totals["potassium"], json!(558.0)); + } + + #[test] + fn test_sum_nutriments_prefers_off_over_est() { + // Entry has both OFF value (0.32g = 320mg) and _est (558mg) + // OFF value should win + let entries = vec![json!({"potassium_100g": 0.32, "potassium_100g_est": 558.0})]; + let totals = tools::sum_nutriments(&entries); + assert_eq!(totals["potassium"], json!(320.0)); // 0.32g × 1000 = 320mg + } + + #[test] + fn test_sum_nutriments_mixed_off_and_est() { + // Entry 1 has OFF potassium (0.32g = 320mg) + // Entry 2 has only _est potassium (558mg from fallback) + let entries = vec![ + json!({"potassium_100g": 0.32}), + json!({"potassium_100g_est": 558.0}), + ]; + let totals = tools::sum_nutriments(&entries); + assert_eq!(totals["potassium"], json!(878.0)); // 320 + 558 + } + + #[test] + fn test_fallback_lookup_exact() { + let table = nutrition_mcp::api::FallbackTable::load(); + let result = table.lookup("spinach"); + assert!(result.is_some()); + assert_eq!(result.unwrap().potassium_100g, Some(558.0)); + } + + #[test] + fn test_fallback_lookup_substring() { + let table = nutrition_mcp::api::FallbackTable::load(); + // "Leaf spinach" should match "spinach" via substring + let result = table.lookup("Leaf spinach"); + assert!(result.is_some()); + assert_eq!(result.unwrap().potassium_100g, Some(558.0)); + } + + #[test] + fn test_fallback_lookup_no_match() { + let table = nutrition_mcp::api::FallbackTable::load(); + let result = table.lookup("alien space dust"); + assert!(result.is_none()); + } + + #[test] + fn test_fallback_lookup_case_insensitive() { + let table = nutrition_mcp::api::FallbackTable::load(); + let result = table.lookup("BANANA"); + assert!(result.is_some()); + assert_eq!(result.unwrap().potassium_100g, Some(358.0)); + } + // ── Date/meal tests ────────────────────────────────────────── #[test]