Inital commit for agent

This commit is contained in:
test committed 2026-09-05 10:39:02 +02:00
commit 82fdc33771
12 files changed
+497

No files matched your search

+211
View File
@@ -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)