Inital commit for agent
This commit is contained in:
commit
82fdc33771
12 files changed
+497
No files matched your search
+211
@@ -0,0 +1,211 @@
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
import requests
|
||||
from typing import Dict, Optional
|
||||
|
||||
# Ensure recipe_parser is importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from recipe_parser import ParsedRecipe
|
||||
|
||||
_BASE_URL = "https://world.openfoodfacts.org"
|
||||
_SEARCH_BASE = _BASE_URL + "/cgi/search.pl"
|
||||
_PRODUCT_BASE = _BASE_URL + "/api/v2/product"
|
||||
|
||||
|
||||
class NutritionApi:
|
||||
"""Wrapper around OpenFoodFacts API for looking up ingredient nutrition."""
|
||||
|
||||
def __init__(self):
|
||||
self.conn = None # SQLite connection set externally if using cache
|
||||
self._last_request = 0.0
|
||||
|
||||
def set_conn(self, conn):
|
||||
"""Set the SQLite connection for caching."""
|
||||
self.conn = conn
|
||||
|
||||
def lookup(self, ingredient: str) -> Optional[Dict]:
|
||||
"""Look up nutrition data for *ingredient*.
|
||||
|
||||
Returns a dict with keys ``calories``, ``protein``, ``fat``,
|
||||
``carbs``, ``sugar``, ``sodium`` – or ``None`` if not found.
|
||||
"""
|
||||
# Check cache first
|
||||
if self.conn:
|
||||
cursor = self.conn.execute(
|
||||
"SELECT data FROM ingredient_cache WHERE ingredient = ?",
|
||||
(ingredient,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
try:
|
||||
return json.loads(row[0])
|
||||
except json.JSONDecodeError:
|
||||
pass # Fall through to API lookup
|
||||
|
||||
self._rate_limit()
|
||||
result = self._search_openfood(ingredient)
|
||||
if result is not None:
|
||||
if self.conn:
|
||||
self.conn.execute(
|
||||
"INSERT OR REPLACE INTO ingredient_cache (ingredient, data) VALUES (?, ?)",
|
||||
(ingredient, json.dumps(result)),
|
||||
)
|
||||
self.conn.commit()
|
||||
return result
|
||||
return None
|
||||
|
||||
def _rate_limit(self) -> None:
|
||||
"""Simple rate limiter: minimum delay between requests."""
|
||||
elapsed = time.time() - self._last_request
|
||||
if elapsed < 1.0: # 1 request per second minimum
|
||||
time.sleep(1.0 - elapsed)
|
||||
self._last_request = time.time()
|
||||
|
||||
def _search_openfood(self, query: str) -> Optional[Dict]:
|
||||
"""Search OpenFoodFacts and return normalised nutrition dict.
|
||||
|
||||
Retries with increasing delays to handle rate limiting / captcha challenges.
|
||||
"""
|
||||
# Retry with exponential backoff for transient errors (e.g. 503, HTML captcha pages)
|
||||
max_retries = 5
|
||||
# Increasing delays: 2, 5, 10, 20, 30 seconds to handle rate limiting / captcha challenges
|
||||
backoff_delays = [2, 5, 10, 20, 30]
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if attempt > 0:
|
||||
delay = backoff_delays[min(attempt - 1, len(backoff_delays) - 1)]
|
||||
time.sleep(delay)
|
||||
params = {"search": query.strip(), "json": "1", "page_size": "5"}
|
||||
r = requests.get(_SEARCH_BASE, params=params, timeout=10,
|
||||
headers={"User-Agent": "RecipeParser/1.0"})
|
||||
# Handle non-JSON responses (e.g. Cloudflare captcha HTML)
|
||||
if r.status_code != 200:
|
||||
if attempt == max_retries - 1:
|
||||
return None
|
||||
continue
|
||||
# Check if response is valid JSON (not HTML error page)
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
# Not JSON – likely a captcha/challenge page; retry with longer delay
|
||||
if attempt == max_retries - 1:
|
||||
print(f"OpenFoodFacts returned non-JSON for '{query}'; giving up")
|
||||
return None
|
||||
continue
|
||||
products = data.get("products", [])
|
||||
if not products:
|
||||
return None
|
||||
|
||||
# Take the first reasonably complete product
|
||||
for prod in products:
|
||||
nutr = prod.get("nutriments", {})
|
||||
# We need at least calories/energy; skip if totally empty
|
||||
if not nutr:
|
||||
continue
|
||||
return {
|
||||
"calories": nutr.get("energy-kcal", 0) or 0,
|
||||
"protein": nutr.get("protein", 0) or 0,
|
||||
"fat": nutr.get("fat", 0) or 0,
|
||||
"carbs": nutr.get("carbohydrates", 0) or 0,
|
||||
"sugar": nutr.get("sugars", 0) or 0,
|
||||
"sodium": nutr.get("sodium", 0) or 0,
|
||||
}
|
||||
return None
|
||||
except Exception as exc: # broad – network / JSON issues
|
||||
if attempt == max_retries - 1:
|
||||
print(f"OpenFoodFacts error for '{query}': {exc}")
|
||||
return None
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
class RecipeParser:
|
||||
"""Parses a markdown recipe string into a structured format."""
|
||||
|
||||
def parse(self, text: str) -> ParsedRecipe:
|
||||
"""Parse a markdown recipe string.
|
||||
|
||||
Returns a ParsedRecipe object with 'servings' and 'ingredients' attributes.
|
||||
"""
|
||||
import re
|
||||
lines = text.strip().split('\n')
|
||||
|
||||
# Default values
|
||||
servings = 4
|
||||
ingredients = []
|
||||
|
||||
# Simple parsing: look for "Makes X servings" line
|
||||
for line in lines:
|
||||
line_lower = line.strip().lower()
|
||||
if 'makes' in line_lower and 'serving' in line_lower:
|
||||
# Extract number before 'servings'
|
||||
match = re.search(r'(\d+)', line)
|
||||
if match:
|
||||
servings = int(match.group(1))
|
||||
break
|
||||
|
||||
# Also look for ingredient lines (lines starting with '- ')
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('- '):
|
||||
ingredients.append(stripped[2:])
|
||||
|
||||
return ParsedRecipe(servings=servings, ingredients=ingredients)
|
||||
|
||||
|
||||
class Recipe:
|
||||
"""Represents a parsed recipe with nutrition lookups."""
|
||||
|
||||
def __init__(self, parsed: ParsedRecipe, api: NutritionApi, servings: int = 4):
|
||||
self.parsed = parsed
|
||||
self.api = api
|
||||
self.servings = parsed.servings if parsed else servings
|
||||
self._nutrition = {}
|
||||
|
||||
def _get_ingredient_nutrition(self, ingredient: str) -> Optional[Dict]:
|
||||
"""Look up nutrition for a single ingredient."""
|
||||
# Try exact match first, then fall back
|
||||
result = self.api.lookup(ingredient)
|
||||
if result is not None:
|
||||
self._nutrition[ingredient] = result
|
||||
return result
|
||||
return None
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Return a text summary of the recipe with nutrition info."""
|
||||
lines = []
|
||||
lines.append(f"Recipe Summary")
|
||||
lines.append(f"Servings: {self.servings}")
|
||||
lines.append("")
|
||||
|
||||
ingredients = self.parsed.ingredients
|
||||
if ingredients:
|
||||
lines.append("Ingredients:")
|
||||
for ing in ingredients:
|
||||
# Get just the ingredient name (before any measurements/units)
|
||||
ing_name = ing.split(',')[0] if ',' in ing else ing.split()[0] if ing.split() else ing
|
||||
nutrition = self._nutrition.get(ing_name)
|
||||
if nutrition:
|
||||
lines.append(f" - {ing_name}: "
|
||||
f"Cal={nutrition.get('calories', '?')}, "
|
||||
f"Pro={nutrition.get('protein', '?')}g, "
|
||||
f"Fat={nutrition.get('fat', '?')}g, "
|
||||
f"Carbs={nutrition.get('carbs', '?')}g")
|
||||
else:
|
||||
lines.append(f" - {ing_name}: nutrition pending")
|
||||
else:
|
||||
lines.append("No ingredients found")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Return a JSON representation of the recipe."""
|
||||
import json as _json
|
||||
data = {
|
||||
'servings': self.servings,
|
||||
'ingredients': self.parsed.ingredients,
|
||||
'nutrition': self._nutrition,
|
||||
}
|
||||
return _json.dumps(data, indent=2)
|
||||
Reference in new issue
Block a user