From 2932fb4e30ccc392d7a738666444849b712da521 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 22:10:30 +0000 Subject: [PATCH 1/2] Add Open Food Facts integration for expanded nutrition lookup - Create lookup_openfoodfacts.py for searching OFF database with barcode support - Create lookup_nutrition.py as unified interface to search both USDA and OFF - Update lookup_nutrition.md skill with new capabilities - Update ISSUES.md: mark issues #1-3 as completed, add new future issues This addresses: - Issue #1: European/Swiss database via OFF country filtering - Issue #2: Expand beyond 365 items via OFF's millions of products - Issue #3: Barcode lookup support via OFF API --- .claude/skills/lookup_nutrition.md | 103 ++++++++----- ISSUES.md | 116 ++++++++++---- scripts/lookup_nutrition.py | 184 ++++++++++++++++++++++ scripts/lookup_openfoodfacts.py | 237 +++++++++++++++++++++++++++++ 4 files changed, 569 insertions(+), 71 deletions(-) create mode 100755 scripts/lookup_nutrition.py create mode 100755 scripts/lookup_openfoodfacts.py diff --git a/.claude/skills/lookup_nutrition.md b/.claude/skills/lookup_nutrition.md index 6b91031..19693a7 100644 --- a/.claude/skills/lookup_nutrition.md +++ b/.claude/skills/lookup_nutrition.md @@ -1,52 +1,90 @@ # Look Up Nutritional Information -Find accurate nutritional data for foods using the local USDA database. +Find accurate nutritional data for foods using multiple databases. -## Primary Source: Local USDA Database +## Available Data Sources -Use the lookup script to search Foundation Foods: +1. **USDA Foundation Foods** (local) - ~365 whole foods, high accuracy +2. **Open Food Facts** (online) - Millions of packaged products, barcodes, European foods + +## Unified Lookup (Recommended) + +Search both databases at once: ```bash -python3 scripts/lookup_usda.py "food name" +python3 scripts/lookup_nutrition.py "food name" ``` Options: -- `--limit N` - Return top N matches (default 5) -- `--portions` - Include standard portion sizes +- `--limit N` - Max results per source (default 5) +- `--portions` - Include standard portion sizes (USDA only) - `--json` - Output as JSON for easier parsing -- `--id FDC_ID` - Look up by specific FDC ID +- `--source usda|off` - Search only one database +- `--country NAME` - Filter Open Food Facts by country (e.g., switzerland, germany) + +### Barcode Lookup -### Example +Look up packaged products by barcode (EAN/UPC): ```bash +python3 scripts/lookup_nutrition.py --barcode 3017620422003 +``` + +### USDA ID Lookup + +Look up by specific USDA FDC ID: + +```bash +python3 scripts/lookup_nutrition.py --id 171705 +``` + +## Individual Scripts + +For specific databases only: + +```bash +# USDA only (local, offline) python3 scripts/lookup_usda.py "chicken breast" --portions + +# Open Food Facts only (online, European foods, barcodes) +python3 scripts/lookup_openfoodfacts.py "gruyere" --country switzerland +python3 scripts/lookup_openfoodfacts.py --barcode 7613035844674 ``` -Returns per-100g nutrient values plus portion options. +## Examples -## Fallback Sources (when not in USDA data) +```bash +# Search all databases +python3 scripts/lookup_nutrition.py "eggs" --limit 3 -1. **User-provided info** - Nutrition labels, specific values -2. **Brand websites** - For packaged/branded foods -3. **Web search** - Last resort, cite source +# Swiss cheeses and European foods +python3 scripts/lookup_nutrition.py "emmental" --country switzerland -## Process +# Packaged product by barcode +python3 scripts/lookup_nutrition.py --barcode 3017620422003 -1. Search local USDA database first -2. If no match or ambiguous, ask for clarification: - - Preparation method (raw, cooked, fried) - - Specific variety - - Brand (for packaged foods) -3. If not in USDA, fall back to other sources -4. Return breakdown with source noted +# Only local USDA data +python3 scripts/lookup_nutrition.py "salmon" --source usda --portions +``` ## Scaling to Portion Size -USDA values are per 100g. To scale: +Values are per 100g. To scale: - Get the amount in grams - Multiply each nutrient by (amount_g / 100) -The `--portions` flag shows common serving sizes with gram weights. +Use `--portions` to see common serving sizes with gram weights (USDA only). + +## Process + +1. Search local USDA database first for whole foods +2. Search Open Food Facts for packaged/branded items +3. If user has a barcode, use barcode lookup +4. If no match or ambiguous, ask for clarification: + - Preparation method (raw, cooked, fried) + - Specific variety + - Brand (for packaged foods) +5. Return breakdown with source noted ## What to Return @@ -61,26 +99,11 @@ If available: - Saturated fat, trans fat - Sodium, potassium - Vitamins and minerals +- Nutri-Score (Open Food Facts) ## Handling Uncertainty - If multiple matches, show options: "Did you mean X or Y?" - If portion unclear, use standard serving from `--portions` - For home-cooked meals, break into ingredients and sum - -## Example Interaction - -User: "One chicken breast" - -```bash -python3 scripts/lookup_usda.py "chicken breast" --portions --json -``` - -Response: "A raw chicken breast with skin (FDC ID: 2727569) per 100g: -- Calories: not listed (estimate ~165 kcal) -- Protein: 21.4g -- Fat: 4.78g -- Carbs: ~0g - -The USDA shows this without calorie data. A typical breast is ~175g. -Should I log 175g chicken breast?" +- Note the source (USDA vs Open Food Facts) in responses diff --git a/ISSUES.md b/ISSUES.md index f32db95..bf34cff 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -2,7 +2,65 @@ ## Recently Completed ✓ -### ~~3. Improve food search/matching~~ ✓ Completed 2026-01-15 +### ~~1. Add European/Swiss nutritional database~~ ✓ Completed 2026-01-15 + +**Labels:** enhancement, data + +Integrated Open Food Facts API with country-specific filtering: +- **International coverage** - Millions of products from around the world +- **Swiss/European foods** - Use `--country switzerland` (or germany, france, etc.) +- **Country-specific subdomains** - Routes to regional OFF databases for better results + +Files added: +- `scripts/lookup_openfoodfacts.py` - Open Food Facts API integration +- `scripts/lookup_nutrition.py` - Unified lookup across all databases + +Example: `python3 scripts/lookup_nutrition.py "gruyere" --country switzerland` + +--- + +### ~~2. Expand food database beyond 365 items~~ ✓ Completed 2026-01-15 + +**Labels:** enhancement, data + +Added Open Food Facts integration providing access to millions of products: +- **No bundling required** - Uses Open Food Facts API (online) +- **Unified search** - Single command searches both USDA and OFF +- **Source tracking** - Results show [USDA] or [OFF] labels + +Files added: +- `scripts/lookup_openfoodfacts.py` +- `scripts/lookup_nutrition.py` + +Updated: `.claude/skills/lookup_nutrition.md` + +--- + +### ~~3. Add barcode/packaging photo support~~ ✓ Completed 2026-01-15 + +**Labels:** enhancement, feature + +Implemented barcode lookup via Open Food Facts: +- **EAN/UPC support** - Look up any barcode in OFF database +- **Nutri-Score included** - Shows grade when available +- **Brand and quantity** - Displays product details + +Usage: +```bash +python3 scripts/lookup_nutrition.py --barcode 3017620422003 +# or +python3 scripts/lookup_openfoodfacts.py --barcode 3017620422003 +``` + +Note: Photo/OCR extraction requires vision-capable LLM to read barcode from image first. + +Files added: +- `scripts/lookup_openfoodfacts.py` +- `scripts/lookup_nutrition.py` + +--- + +### ~~4. Improve food search/matching~~ ✓ Completed 2026-01-15 **Labels:** enhancement @@ -59,55 +117,51 @@ Copy these to GitHub Issues when ready. --- -## 1. Add European/Swiss nutritional database +## 7. Add dedicated Swiss database integration **Labels:** enhancement, data ### Problem -The current USDA Foundation Foods dataset is US-focused. Missing: -- Swiss cheeses (Gruyère, Emmental, Appenzeller, Raclette) -- European sausages (Cervelat, Bratwurst) -- Regional foods and preparations +While Open Food Facts provides some Swiss products, the official Swiss Food Composition Database (Schweizer Nährwertdatenbank) at https://naehrwertdaten.ch/ has more accurate data for traditional Swiss foods. ### Potential sources -- **Swiss Food Composition Database** (Schweizer Nährwertdatenbank) - https://naehrwertdaten.ch/ -- **German BLS** (Bundeslebensmittelschlüssel) -- **Open Food Facts** - crowdsourced, international coverage +- **Swiss Food Composition Database** - Official government data +- **German BLS** (Bundeslebensmittelschlüssel) - Comprehensive German database ### Implementation -- Download and integrate additional database(s) -- Update `lookup_usda.py` to search multiple sources (or create unified `lookup_nutrition.py`) -- Add source field to track where data came from +- Download official Swiss database +- Create `lookup_swiss.py` script +- Integrate into unified `lookup_nutrition.py` --- -## 2. Expand food database beyond 365 items - -**Labels:** enhancement, data +## 8. Image recognition for meals -### Problem -USDA Foundation Foods only has 365 foods. Missing many common items. +**Labels:** enhancement, feature -### Options -- **USDA SR Legacy** - ~8,000 foods, older but more comprehensive -- **USDA Branded Foods** - 300k+ items but 3GB (too large to bundle) -- **Open Food Facts** - millions of products, crowdsourced +### Description +Allow users to photograph a meal and have it analyzed: +1. Vision LLM identifies food items in the photo +2. Estimates portions based on visual cues +3. Looks up nutrition and logs the meal -### Consideration -Trade-off between database size and repo size. Could offer download script instead of bundling. +### Dependencies +- Requires multimodal LLM with vision capabilities +- Good portion estimation logic --- -## 3. Add barcode/packaging photo support +## 9. Goal tracking and recommendations **Labels:** enhancement, feature ### Description -When user photographs food packaging: -1. Extract barcode or product name -2. Look up in Open Food Facts or similar -3. Auto-populate nutritional values +Provide actionable recommendations based on intake patterns: +- Suggest foods to fill nutrient gaps +- Alert when approaching daily limits +- Weekly goal progress tracking -### Dependencies -- Requires vision-capable LLM -- Open Food Facts API or local database +### Implementation +- Analyze weekly_summary data +- Build recommendation engine +- Add notification/alert system diff --git a/scripts/lookup_nutrition.py b/scripts/lookup_nutrition.py new file mode 100755 index 0000000..38db11c --- /dev/null +++ b/scripts/lookup_nutrition.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Unified nutrition lookup - searches USDA and Open Food Facts databases. + +This script provides a single interface to search multiple nutrition databases: +- USDA Foundation Foods (bundled, ~365 whole foods) +- Open Food Facts (online, millions of packaged products, barcodes) + +Prioritizes USDA for whole foods, Open Food Facts for branded/packaged items. +""" + +import argparse +import json +import sys +from pathlib import Path + +# Import the individual lookup modules +script_dir = Path(__file__).parent +sys.path.insert(0, str(script_dir)) + +import lookup_usda +import lookup_openfoodfacts as off + + +def search_all(query: str, limit: int = 5, source: str = None, country: str = None) -> list: + """Search all available databases. + + Args: + query: Search terms + limit: Max results per source (default 5) + source: Limit to specific source ('usda' or 'off') + country: Country filter for Open Food Facts + + Returns: + List of results with source information + """ + results = [] + + # Search USDA first (local, faster, better for whole foods) + if source in (None, "usda"): + try: + usda_results = lookup_usda.search_foods(query, limit) + for food in usda_results: + nutrients = lookup_usda.extract_nutrients(food) + nutrients["source"] = "usda" + nutrients["portions"] = lookup_usda.get_portions(food) + results.append(nutrients) + except Exception as e: + print(f"USDA search error: {e}", file=sys.stderr) + + # Then search Open Food Facts (online, slower, but millions of products) + if source in (None, "off"): + try: + off_results = off.search_by_text(query, limit, country) + for product in off_results: + nutrients = off.extract_nutrients(product) + results.append(nutrients) + except Exception as e: + print(f"Open Food Facts search error: {e}", file=sys.stderr) + + return results + + +def lookup_barcode(barcode: str) -> dict: + """Look up a product by barcode.""" + product = off.search_by_barcode(barcode) + if product: + return off.extract_nutrients(product) + return None + + +def lookup_usda_id(fdc_id: int) -> dict: + """Look up a USDA food by FDC ID.""" + foods = lookup_usda.load_data() + for food in foods: + if food["fdcId"] == fdc_id: + nutrients = lookup_usda.extract_nutrients(food) + nutrients["source"] = "usda" + nutrients["portions"] = lookup_usda.get_portions(food) + return nutrients + return None + + +def format_result(r: dict, show_portions: bool = False) -> str: + """Format a result for display.""" + lines = [] + + name = r["food_name"] + if r.get("brand"): + name = f"{r['brand']} - {name}" + if r.get("quantity"): + name = f"{name} ({r['quantity']})" + + source_label = "USDA" if r.get("source") == "usda" else "OFF" + id_info = f"FDC:{r['fdcId']}" if r.get("fdcId") else f"BC:{r.get('barcode', 'N/A')}" + + lines.append(f"\n{name}") + lines.append(f"[{source_label}] {id_info}") + if r.get("nutriscore"): + lines.append(f"Nutri-Score: {r['nutriscore']}") + lines.append("-" * 40) + + # Macros first + macros = ["calories", "protein_g", "carbs_g", "fat_g", "fiber_g", "sugar_g"] + for m in macros: + if m in r: + lines.append(f" {m}: {r[m]}") + + # Other nutrients + lines.append(" ---") + skip = {"food_name", "fdcId", "barcode", "brand", "quantity", "source", "nutriscore", "portions"} | set(macros) + for k, v in sorted(r.items()): + if k not in skip: + lines.append(f" {k}: {v}") + + # Portions + if show_portions and r.get("portions"): + lines.append(" ---") + lines.append(" Portions:") + for p in r["portions"]: + lines.append(f" {p['name']}: {p['grams']}g") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Search USDA and Open Food Facts for nutrition info", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s "chicken breast" # Search both databases + %(prog)s "gruyere" --country switzerland # Include Swiss products + %(prog)s --barcode 3017620422003 # Look up by barcode + %(prog)s --id 171705 # Look up by USDA FDC ID + %(prog)s "eggs" --source usda # Only search USDA + %(prog)s "nutella" --source off # Only search Open Food Facts + """ + ) + parser.add_argument("query", nargs="?", help="Food to search for") + parser.add_argument("--barcode", "-b", help="Look up by barcode (EAN/UPC)") + parser.add_argument("--id", type=int, help="Look up by USDA FDC ID") + parser.add_argument("--source", "-s", choices=["usda", "off"], help="Search only this source") + parser.add_argument("--country", "-c", help="Country filter for Open Food Facts") + parser.add_argument("--limit", type=int, default=5, help="Max results per source (default 5)") + parser.add_argument("--json", action="store_true", help="Output as JSON") + parser.add_argument("--portions", action="store_true", help="Include portion sizes (USDA only)") + + args = parser.parse_args() + + if not args.query and not args.barcode and not args.id: + parser.print_help() + return + + results = [] + + if args.barcode: + result = lookup_barcode(args.barcode) + if result: + results = [result] + else: + print(f"No product found with barcode: {args.barcode}") + return + elif args.id: + result = lookup_usda_id(args.id) + if result: + results = [result] + else: + print(f"No food found with USDA FDC ID: {args.id}") + return + else: + results = search_all(args.query, args.limit, args.source, args.country) + if not results: + print(f"No foods found matching: {args.query}") + return + + if args.json: + print(json.dumps(results, indent=2)) + else: + for r in results: + print(format_result(r, args.portions)) + + +if __name__ == "__main__": + main() diff --git a/scripts/lookup_openfoodfacts.py b/scripts/lookup_openfoodfacts.py new file mode 100755 index 0000000..1bb09fb --- /dev/null +++ b/scripts/lookup_openfoodfacts.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Search Open Food Facts database for nutritional information. + +Open Food Facts is a free, open database with millions of food products +from around the world, including European foods, barcodes, and branded items. +""" + +import argparse +import json +import sys +import urllib.request +import urllib.parse +from typing import Optional + +BASE_URL = "https://world.openfoodfacts.org" +USER_AGENT = "BiteBot/1.0 (nutrition-tracker)" + +# Map Open Food Facts nutrient fields to our CSV columns +NUTRIENT_MAP = { + "energy-kcal_100g": "calories", + "proteins_100g": "protein_g", + "carbohydrates_100g": "carbs_g", + "fiber_100g": "fiber_g", + "sugars_100g": "sugar_g", + "fat_100g": "fat_g", + "saturated-fat_100g": "saturated_fat_g", + "trans-fat_100g": "trans_fat_g", + "cholesterol_100g": "cholesterol_mg", + "sodium_100g": "sodium_mg", + "potassium_100g": "potassium_mg", + "calcium_100g": "calcium_mg", + "iron_100g": "iron_mg", + "magnesium_100g": "magnesium_mg", + "zinc_100g": "zinc_mg", + "vitamin-a_100g": "vitamin_a_mcg", + "vitamin-c_100g": "vitamin_c_mg", + "vitamin-d_100g": "vitamin_d_mcg", + "vitamin-e_100g": "vitamin_e_mg", + "vitamin-b1_100g": "vitamin_b1_mg", + "vitamin-b2_100g": "vitamin_b2_mg", + "vitamin-b6_100g": "vitamin_b6_mg", + "vitamin-b12_100g": "vitamin_b12_mcg", + "folates_100g": "vitamin_b9_mcg", + "caffeine_100g": "caffeine_mg", +} + + +def fetch_url(url: str) -> dict: + """Fetch JSON from URL with proper headers.""" + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + if e.code == 404: + return {"status": 0, "products": []} + raise + except urllib.error.URLError as e: + print(f"Network error: {e.reason}", file=sys.stderr) + sys.exit(1) + + +def search_by_barcode(barcode: str) -> Optional[dict]: + """Look up a product by barcode (EAN/UPC).""" + url = f"{BASE_URL}/api/v2/product/{barcode}.json" + data = fetch_url(url) + + if data.get("status") == 1 and "product" in data: + return data["product"] + return None + + +def search_by_text(query: str, limit: int = 10, country: str = None) -> list: + """Search products by text query. + + Args: + query: Search terms + limit: Max results (default 10) + country: Filter by country code (e.g., 'switzerland', 'germany', 'france') + """ + params = { + "search_terms": query, + "search_simple": 1, + "action": "process", + "json": 1, + "page_size": limit, + "fields": "code,product_name,brands,quantity,nutriments,nutriscore_grade,categories_tags", + } + + base = BASE_URL + if country: + # Use country-specific subdomain for better results + country_codes = { + "switzerland": "ch", + "swiss": "ch", + "germany": "de", + "german": "de", + "france": "fr", + "french": "fr", + "italy": "it", + "italian": "it", + "uk": "uk", + "united kingdom": "uk", + "us": "us", + "usa": "us", + } + code = country_codes.get(country.lower(), country.lower()) + base = f"https://{code}.openfoodfacts.org" + + url = f"{base}/cgi/search.pl?" + urllib.parse.urlencode(params) + data = fetch_url(url) + + return data.get("products", []) + + +def extract_nutrients(product: dict) -> dict: + """Extract nutrient values mapped to our CSV columns.""" + nutriments = product.get("nutriments", {}) + + result = { + "food_name": product.get("product_name", "Unknown"), + "barcode": product.get("code", ""), + "brand": product.get("brands", ""), + "quantity": product.get("quantity", ""), + "source": "openfoodfacts", + } + + # Add nutriscore if available + if product.get("nutriscore_grade"): + result["nutriscore"] = product["nutriscore_grade"].upper() + + # Extract nutrients - values are per 100g + for off_key, our_key in NUTRIENT_MAP.items(): + value = nutriments.get(off_key) + if value is not None: + try: + result[our_key] = round(float(value), 3) + except (ValueError, TypeError): + pass + + # Handle sodium -> mg conversion if stored in g + if "sodium_mg" not in result and "sodium_100g" in nutriments: + try: + # OFF stores sodium in g, we want mg + result["sodium_mg"] = round(float(nutriments["sodium_100g"]) * 1000, 1) + except (ValueError, TypeError): + pass + + return result + + +def format_product(product: dict, show_json: bool = False) -> str: + """Format a product for display.""" + nutrients = extract_nutrients(product) + + if show_json: + return json.dumps(nutrients, indent=2) + + lines = [] + name = nutrients["food_name"] + if nutrients.get("brand"): + name = f"{nutrients['brand']} - {name}" + if nutrients.get("quantity"): + name = f"{name} ({nutrients['quantity']})" + + lines.append(f"\n{name}") + if nutrients.get("barcode"): + lines.append(f"Barcode: {nutrients['barcode']}") + if nutrients.get("nutriscore"): + lines.append(f"Nutri-Score: {nutrients['nutriscore']}") + lines.append("-" * 40) + + # Macros first + macros = ["calories", "protein_g", "carbs_g", "fat_g", "fiber_g", "sugar_g"] + for m in macros: + if m in nutrients: + lines.append(f" {m}: {nutrients[m]}") + + # Then other nutrients + lines.append(" ---") + skip = {"food_name", "barcode", "brand", "quantity", "source", "nutriscore"} | set(macros) + for k, v in sorted(nutrients.items()): + if k not in skip: + lines.append(f" {k}: {v}") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Search Open Food Facts database", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s "greek yogurt" # Search by name + %(prog)s --barcode 7613035844674 # Look up by barcode + %(prog)s "gruyere" --country switzerland # Search Swiss products + %(prog)s "nutella" --json # Output as JSON + """ + ) + parser.add_argument("query", nargs="?", help="Food to search for") + parser.add_argument("--barcode", "-b", help="Look up by barcode (EAN/UPC)") + parser.add_argument("--country", "-c", help="Filter by country (e.g., switzerland, germany)") + parser.add_argument("--limit", type=int, default=5, help="Max results (default 5)") + parser.add_argument("--json", action="store_true", help="Output as JSON") + + args = parser.parse_args() + + if not args.query and not args.barcode: + parser.print_help() + return + + results = [] + + if args.barcode: + product = search_by_barcode(args.barcode) + if product: + results = [product] + else: + print(f"No product found with barcode: {args.barcode}") + return + else: + results = search_by_text(args.query, args.limit, args.country) + if not results: + print(f"No products found matching: {args.query}") + return + + if args.json: + output = [extract_nutrients(p) for p in results] + print(json.dumps(output, indent=2)) + else: + for product in results: + print(format_product(product)) + + +if __name__ == "__main__": + main() From 701e4264a6b99caed1fb9e622cff5d7cf4c59f84 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 22:11:21 +0000 Subject: [PATCH 2/2] Add .gitignore for Python cache and common artifacts --- .gitignore | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c87b898 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ + +# Environment +.env +.venv/ +venv/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db