feat: fallback nutrient estimation for potassium/calcium/magnesium
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)
This commit is contained in:
parent
a072c6ba5e
commit
d0b7b003f9
4 changed files with 281 additions and 5 deletions
116
data/fallback_nutrients.json
Normal file
116
data/fallback_nutrients.json
Normal file
|
|
@ -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 }
|
||||
}
|
||||
57
src/api.rs
57
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<String, FallbackNutrients>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct FallbackNutrients {
|
||||
#[serde(default)]
|
||||
pub potassium_100g: Option<f64>, // mg
|
||||
#[serde(default)]
|
||||
pub calcium_100g: Option<f64>, // mg
|
||||
#[serde(default)]
|
||||
pub magnesium_100g: Option<f64>, // 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<String, FallbackNutrients> =
|
||||
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,
|
||||
|
|
|
|||
48
src/tools.rs
48
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue