Files
simbarag/utils/mealie_service.py
ryan cd94f98c63 Integrate Mealie meal planning service into LangChain agent
Wires up the existing MealieService to the conversational agent with tools
for querying today's meals, weekly meal plans, full recipes, shopping lists,
and creating new meal plan entries. Adds env vars to docker-compose and
updates the system prompt so the agent knows when to use each tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 10:59:49 -04:00

530 lines
19 KiB
Python

"""Mealie API service for querying meal plans, recipes, and shopping lists."""
import os
import logging
from datetime import datetime, timedelta
from typing import Any, Optional
import httpx
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Configure logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
class MealieService:
"""Service for interacting with Mealie API."""
def __init__(self):
"""Initialize Mealie API client."""
logger.info("[MEALIE] Initializing Mealie service...")
self.base_url = os.getenv("MEALIE_BASE_URL", "")
self.api_token = os.getenv("MEALIE_API_TOKEN", "")
if not self.base_url:
logger.error("[MEALIE] MEALIE_BASE_URL not found in environment")
raise ValueError("MEALIE_BASE_URL environment variable is required")
if not self.api_token:
logger.error("[MEALIE] MEALIE_API_TOKEN not found in environment")
raise ValueError("MEALIE_API_TOKEN environment variable is required")
# Remove trailing slash from base URL if present
self.base_url = self.base_url.rstrip("/")
logger.info(f"[MEALIE] Base URL: {self.base_url}")
logger.info(f"[MEALIE] API token found: {self.api_token[:10]}...")
# Create HTTP client
self.client = httpx.AsyncClient(
headers={
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
},
timeout=30.0,
)
logger.info("[MEALIE] Mealie service initialized successfully")
async def close(self):
"""Close the HTTP client."""
await self.client.aclose()
async def get_shopping_lists(self) -> dict[str, Any]:
"""Get all shopping lists with their items.
Returns:
Dictionary containing shopping lists and their items.
"""
logger.info("[MEALIE] get_shopping_lists() called")
try:
logger.info("[MEALIE] Fetching shopping lists from Mealie API...")
response = await self.client.get(
f"{self.base_url}/api/households/shopping/lists"
)
response.raise_for_status()
data = response.json()
logger.info(
f"[MEALIE] Retrieved {len(data.get('items', []))} shopping lists"
)
shopping_lists = []
total_unchecked_items = 0
for shopping_list in data.get("items", []):
list_id = shopping_list.get("id")
list_name = shopping_list.get("name", "Unnamed List")
# Get items for this list
items_response = await self.client.get(
f"{self.base_url}/api/households/shopping/items",
params={"shopping_list_id": list_id},
)
items_response.raise_for_status()
items_data = items_response.json()
unchecked_items = []
checked_items = []
for item in items_data.get("items", []):
item_data = {
"name": item.get("display", item.get("note", "Unknown item")),
"quantity": item.get("quantity", 1),
"note": item.get("note", ""),
"food": item.get("foodId"),
}
if item.get("checked", False):
checked_items.append(item_data)
else:
unchecked_items.append(item_data)
total_unchecked_items += 1
shopping_lists.append(
{
"id": list_id,
"name": list_name,
"unchecked_items": unchecked_items,
"checked_items": checked_items,
"total_items": len(unchecked_items) + len(checked_items),
}
)
logger.info(
f"[MEALIE] Processed {len(shopping_lists)} lists with {total_unchecked_items} unchecked items"
)
return {
"shopping_lists": shopping_lists,
"total_lists": len(shopping_lists),
"total_unchecked_items": total_unchecked_items,
}
except httpx.HTTPStatusError as e:
logger.error(
f"[MEALIE] HTTP error in get_shopping_lists(): {e.response.status_code} - {e.response.text}"
)
raise
except Exception as e:
logger.error(
f"[MEALIE] Error in get_shopping_lists(): {type(e).__name__}: {str(e)}"
)
logger.exception("[MEALIE] Full traceback:")
raise
async def get_meal_plans(
self,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> dict[str, Any]:
"""Get meal plans for a date range.
Args:
start_date: Start date in YYYY-MM-DD format (defaults to today)
end_date: End date in YYYY-MM-DD format (defaults to 7 days from start)
Returns:
Dictionary containing meal plans for the specified date range.
"""
logger.info(
f"[MEALIE] get_meal_plans() called - start_date={start_date}, end_date={end_date}"
)
try:
# Set default dates
if not start_date:
start_date = datetime.now().strftime("%Y-%m-%d")
if not end_date:
# Default to 7 days from start
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = start_dt + timedelta(days=7)
end_date = end_dt.strftime("%Y-%m-%d")
logger.info(f"[MEALIE] Fetching meal plans from {start_date} to {end_date}")
response = await self.client.get(
f"{self.base_url}/api/households/mealplans",
params={"start_date": start_date, "end_date": end_date},
)
response.raise_for_status()
data = response.json()
logger.info(f"[MEALIE] Retrieved {len(data.get('items', []))} meal plans")
meal_plans = []
for plan in data.get("items", []):
meal_plans.append(
{
"id": plan.get("id"),
"date": plan.get("date"),
"entry_type": plan.get("entryType", "unknown"),
"title": plan.get("title", ""),
"recipe_name": plan.get("recipeName", ""),
"recipe_slug": plan.get("recipeSlug", ""),
"note": plan.get("note", ""),
}
)
# Group by date
plans_by_date = {}
for plan in meal_plans:
date = plan["date"]
if date not in plans_by_date:
plans_by_date[date] = []
plans_by_date[date].append(plan)
logger.info(f"[MEALIE] Organized into {len(plans_by_date)} unique dates")
return {
"meal_plans": meal_plans,
"plans_by_date": plans_by_date,
"start_date": start_date,
"end_date": end_date,
"total_plans": len(meal_plans),
}
except httpx.HTTPStatusError as e:
logger.error(
f"[MEALIE] HTTP error in get_meal_plans(): {e.response.status_code} - {e.response.text}"
)
raise
except Exception as e:
logger.error(
f"[MEALIE] Error in get_meal_plans(): {type(e).__name__}: {str(e)}"
)
logger.exception("[MEALIE] Full traceback:")
raise
async def get_todays_meals(self) -> dict[str, Any]:
"""Get today's meal plans.
Returns:
Dictionary containing today's meal plans.
"""
logger.info("[MEALIE] get_todays_meals() called")
try:
logger.info("[MEALIE] Fetching today's meals from Mealie API...")
response = await self.client.get(
f"{self.base_url}/api/households/mealplans/today"
)
response.raise_for_status()
data = response.json()
logger.info(
f"[MEALIE] Retrieved {len(data.get('items', []))} meals for today"
)
meals = []
for plan in data.get("items", []):
meals.append(
{
"id": plan.get("id"),
"entry_type": plan.get("entryType", "unknown"),
"title": plan.get("title", ""),
"recipe_name": plan.get("recipeName", ""),
"recipe_slug": plan.get("recipeSlug", ""),
"note": plan.get("note", ""),
}
)
return {
"today": datetime.now().strftime("%Y-%m-%d"),
"meals": meals,
"total_meals": len(meals),
}
except httpx.HTTPStatusError as e:
logger.error(
f"[MEALIE] HTTP error in get_todays_meals(): {e.response.status_code} - {e.response.text}"
)
raise
except Exception as e:
logger.error(
f"[MEALIE] Error in get_todays_meals(): {type(e).__name__}: {str(e)}"
)
logger.exception("[MEALIE] Full traceback:")
raise
async def get_recipe(self, recipe_slug: str) -> dict[str, Any]:
"""Get recipe details by slug.
Args:
recipe_slug: The recipe's slug identifier
Returns:
Dictionary containing recipe details including ingredients and instructions.
"""
logger.info(f"[MEALIE] get_recipe() called - recipe_slug={recipe_slug}")
try:
logger.info(f"[MEALIE] Fetching recipe '{recipe_slug}' from Mealie API...")
response = await self.client.get(
f"{self.base_url}/api/households/self/recipes/{recipe_slug}"
)
response.raise_for_status()
recipe = response.json()
logger.info(f"[MEALIE] Retrieved recipe: {recipe.get('name', 'Unknown')}")
# Extract key information
ingredients = []
for ingredient in recipe.get("recipeIngredient", []):
if isinstance(ingredient, dict):
ingredients.append(
{
"display": ingredient.get("display", ""),
"quantity": ingredient.get("quantity"),
"unit": ingredient.get("unit", {}).get("name", ""),
"food": ingredient.get("food", {}).get("name", ""),
"note": ingredient.get("note", ""),
}
)
else:
# Sometimes ingredients are just strings
ingredients.append({"display": str(ingredient)})
instructions = []
for instruction in recipe.get("recipeInstructions", []):
if isinstance(instruction, dict):
instructions.append(
{
"title": instruction.get("title", ""),
"text": instruction.get("text", ""),
}
)
else:
instructions.append({"text": str(instruction)})
return {
"name": recipe.get("name", "Unknown Recipe"),
"slug": recipe_slug,
"description": recipe.get("description", ""),
"ingredients": ingredients,
"instructions": instructions,
"prep_time": recipe.get("prepTime", ""),
"cook_time": recipe.get("performTime", ""),
"total_time": recipe.get("totalTime", ""),
"servings": recipe.get("recipeYield", ""),
"tags": [tag.get("name", "") for tag in recipe.get("tags", [])],
}
except httpx.HTTPStatusError as e:
logger.error(
f"[MEALIE] HTTP error in get_recipe(): {e.response.status_code} - {e.response.text}"
)
raise
except Exception as e:
logger.error(
f"[MEALIE] Error in get_recipe(): {type(e).__name__}: {str(e)}"
)
logger.exception("[MEALIE] Full traceback:")
raise
async def create_meal_plan(
self,
date: str,
entry_type: str = "dinner",
recipe_slug: Optional[str] = None,
title: Optional[str] = None,
note: Optional[str] = None,
) -> dict[str, Any]:
"""Create a new meal plan entry.
Args:
date: Date in YYYY-MM-DD format
entry_type: Type of meal (breakfast, lunch, dinner, side)
recipe_slug: Recipe slug to add to the meal plan (optional)
title: Custom title for the meal (optional, used if no recipe)
note: Additional notes for the meal (optional)
Returns:
Dictionary containing the created meal plan entry.
"""
logger.info(
f"[MEALIE] create_meal_plan() called - date={date}, entry_type={entry_type}, recipe={recipe_slug}"
)
try:
# Build request body
body = {
"date": date,
"entryType": entry_type,
}
if recipe_slug:
body["recipeId"] = recipe_slug # API might use recipeId or recipeSlug
if title:
body["title"] = title
if note:
body["text"] = note # API might use 'text' or 'note'
logger.info(f"[MEALIE] Creating meal plan with body: {body}")
response = await self.client.post(
f"{self.base_url}/api/households/mealplans", json=body
)
response.raise_for_status()
data = response.json()
logger.info(
f"[MEALIE] Successfully created meal plan entry with ID: {data.get('id')}"
)
return {
"id": data.get("id"),
"date": data.get("date"),
"entry_type": data.get("entryType"),
"title": data.get("title"),
"recipe_name": data.get("recipeName"),
"note": data.get("text", data.get("note", "")),
"success": True,
}
except httpx.HTTPStatusError as e:
logger.error(
f"[MEALIE] HTTP error in create_meal_plan(): {e.response.status_code} - {e.response.text}"
)
raise
except Exception as e:
logger.error(
f"[MEALIE] Error in create_meal_plan(): {type(e).__name__}: {str(e)}"
)
logger.exception("[MEALIE] Full traceback:")
raise
async def update_meal_plan(
self,
item_id: int,
date: Optional[str] = None,
entry_type: Optional[str] = None,
recipe_slug: Optional[str] = None,
title: Optional[str] = None,
note: Optional[str] = None,
) -> dict[str, Any]:
"""Update an existing meal plan entry.
Args:
item_id: ID of the meal plan entry to update
date: Date in YYYY-MM-DD format (optional)
entry_type: Type of meal (breakfast, lunch, dinner, side) (optional)
recipe_slug: Recipe slug to update (optional)
title: Custom title for the meal (optional)
note: Additional notes for the meal (optional)
Returns:
Dictionary containing the updated meal plan entry.
"""
logger.info(f"[MEALIE] update_meal_plan() called - item_id={item_id}")
try:
# Build request body with only provided fields
body = {}
if date:
body["date"] = date
if entry_type:
body["entryType"] = entry_type
if recipe_slug:
body["recipeId"] = recipe_slug
if title:
body["title"] = title
if note:
body["text"] = note
logger.info(f"[MEALIE] Updating meal plan {item_id} with body: {body}")
response = await self.client.put(
f"{self.base_url}/api/households/mealplans/{item_id}", json=body
)
response.raise_for_status()
data = response.json()
logger.info(f"[MEALIE] Successfully updated meal plan entry {item_id}")
return {
"id": data.get("id"),
"date": data.get("date"),
"entry_type": data.get("entryType"),
"title": data.get("title"),
"recipe_name": data.get("recipeName"),
"note": data.get("text", data.get("note", "")),
"success": True,
}
except httpx.HTTPStatusError as e:
logger.error(
f"[MEALIE] HTTP error in update_meal_plan(): {e.response.status_code} - {e.response.text}"
)
raise
except Exception as e:
logger.error(
f"[MEALIE] Error in update_meal_plan(): {type(e).__name__}: {str(e)}"
)
logger.exception("[MEALIE] Full traceback:")
raise
async def delete_meal_plan(self, item_id: int) -> dict[str, Any]:
"""Delete a meal plan entry.
Args:
item_id: ID of the meal plan entry to delete
Returns:
Dictionary containing success status and deleted entry info.
"""
logger.info(f"[MEALIE] delete_meal_plan() called - item_id={item_id}")
try:
response = await self.client.delete(
f"{self.base_url}/api/households/mealplans/{item_id}"
)
response.raise_for_status()
data = response.json()
logger.info(f"[MEALIE] Successfully deleted meal plan entry {item_id}")
return {
"id": item_id,
"deleted": True,
"entry": data,
}
except httpx.HTTPStatusError as e:
logger.error(
f"[MEALIE] HTTP error in delete_meal_plan(): {e.response.status_code} - {e.response.text}"
)
raise
except Exception as e:
logger.error(
f"[MEALIE] Error in delete_meal_plan(): {type(e).__name__}: {str(e)}"
)
logger.exception("[MEALIE] Full traceback:")
raise