From cd94f98c63a381c77c3efaa9625d9a4432edd076 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Fri, 12 Jun 2026 10:59:49 -0400 Subject: [PATCH] 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 --- blueprints/conversation/agents.py | 236 +++++++++++++ blueprints/conversation/prompts.py | 9 + docker-compose.yml | 2 + utils/mealie_service.py | 529 +++++++++++++++++++++++++++++ 4 files changed, 776 insertions(+) create mode 100644 utils/mealie_service.py diff --git a/blueprints/conversation/agents.py b/blueprints/conversation/agents.py index fe90b1d..04c9f4c 100644 --- a/blueprints/conversation/agents.py +++ b/blueprints/conversation/agents.py @@ -11,6 +11,7 @@ from tavily import AsyncTavilyClient from blueprints.conversation.memory import save_memory from blueprints.rag.logic import query_vector_store +from utils.mealie_service import MealieService from utils.obsidian_service import ObsidianService from utils.simbakit_service import SimbaKitService from utils.ynab_service import YNABService @@ -52,6 +53,14 @@ except (ValueError, Exception) as e: print(f"SimbaKit service not initialized: {e}") simbakit_enabled = False +# Initialize Mealie service (will only work if MEALIE_BASE_URL is set) +try: + mealie_service = MealieService() + mealie_enabled = True +except (ValueError, Exception) as e: + print(f"Mealie service not initialized: {e}") + mealie_enabled = False + # Initialize Obsidian service (will only work if OBSIDIAN_VAULT_PATH is set) try: obsidian_service = ObsidianService() @@ -318,6 +327,223 @@ def ynab_insights(months_back: int = 3) -> str: return f"Error generating insights: {str(e)}" +@tool +async def mealie_todays_meals() -> str: + """Get today's meal plan from Mealie. + + Use this tool when the user asks about: + - What's for dinner/lunch/breakfast today + - Today's meal plan + - What meals are planned for today + + Returns: + Today's planned meals with recipe names and meal types. + """ + if not mealie_enabled: + return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables." + + try: + result = await mealie_service.get_todays_meals() + + if result["total_meals"] == 0: + return f"No meals planned for today ({result['today']})." + + lines = [f"Meals planned for today ({result['today']}):"] + for meal in result["meals"]: + name = meal["recipe_name"] or meal["title"] or "Untitled" + lines.append(f"- {meal['entry_type'].capitalize()}: {name}") + if meal["note"]: + lines.append(f" Note: {meal['note']}") + return "\n".join(lines) + except Exception as e: + return f"Error fetching today's meals: {str(e)}" + + +@tool +async def mealie_meal_plans(start_date: str = "", end_date: str = "") -> str: + """Get meal plans for a date range from Mealie. + + Use this tool when the user asks about: + - Meal plans for this week or a specific date range + - What's planned for upcoming meals + - Weekly meal schedule + + Args: + start_date: Start date in YYYY-MM-DD format (optional, defaults to today) + end_date: End date in YYYY-MM-DD format (optional, defaults to 7 days from start) + + Returns: + Meal plans organized by date. + """ + if not mealie_enabled: + return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables." + + try: + result = await mealie_service.get_meal_plans( + start_date=start_date or None, + end_date=end_date or None, + ) + + if result["total_plans"] == 0: + return f"No meal plans found from {result['start_date']} to {result['end_date']}." + + lines = [ + f"Meal plans from {result['start_date']} to {result['end_date']} ({result['total_plans']} meals):" + ] + for date, plans in sorted(result["plans_by_date"].items()): + lines.append(f"\n{date}:") + for plan in plans: + name = plan["recipe_name"] or plan["title"] or "Untitled" + lines.append(f" - {plan['entry_type'].capitalize()}: {name}") + if plan["note"]: + lines.append(f" Note: {plan['note']}") + return "\n".join(lines) + except Exception as e: + return f"Error fetching meal plans: {str(e)}" + + +@tool +async def mealie_get_recipe(recipe_slug: str) -> str: + """Get full recipe details from Mealie including ingredients and instructions. + + Use this tool when the user asks about: + - Recipe details, ingredients, or instructions + - How to make a specific recipe + - What ingredients are needed for a meal + + Args: + recipe_slug: The recipe's slug identifier (from meal plan data) + + Returns: + Full recipe with ingredients, instructions, prep/cook times, and servings. + """ + if not mealie_enabled: + return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables." + + try: + recipe = await mealie_service.get_recipe(recipe_slug) + + lines = [f"**{recipe['name']}**"] + if recipe["description"]: + lines.append(recipe["description"]) + lines.append("") + + if recipe["servings"]: + lines.append(f"Servings: {recipe['servings']}") + times = [] + if recipe["prep_time"]: + times.append(f"Prep: {recipe['prep_time']}") + if recipe["cook_time"]: + times.append(f"Cook: {recipe['cook_time']}") + if recipe["total_time"]: + times.append(f"Total: {recipe['total_time']}") + if times: + lines.append(" | ".join(times)) + + if recipe["ingredients"]: + lines.append("\nIngredients:") + for ing in recipe["ingredients"]: + lines.append(f"- {ing['display']}") + + if recipe["instructions"]: + lines.append("\nInstructions:") + for i, step in enumerate(recipe["instructions"], 1): + text = step.get("text", "") + lines.append(f"{i}. {text}") + + if recipe["tags"]: + lines.append(f"\nTags: {', '.join(recipe['tags'])}") + + return "\n".join(lines) + except Exception as e: + return f"Error fetching recipe: {str(e)}" + + +@tool +async def mealie_shopping_lists() -> str: + """Get shopping lists and their items from Mealie. + + Use this tool when the user asks about: + - What's on the shopping list + - What groceries are needed + - Shopping list items + + Returns: + Shopping lists with unchecked (needed) and checked (done) items. + """ + if not mealie_enabled: + return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables." + + try: + result = await mealie_service.get_shopping_lists() + + if result["total_lists"] == 0: + return "No shopping lists found." + + lines = [f"Shopping lists ({result['total_unchecked_items']} items needed):"] + for sl in result["shopping_lists"]: + lines.append( + f"\n**{sl['name']}** ({len(sl['unchecked_items'])} items needed)" + ) + if sl["unchecked_items"]: + for item in sl["unchecked_items"]: + qty = ( + f" x{item['quantity']}" + if item["quantity"] and item["quantity"] != 1 + else "" + ) + lines.append(f" - [ ] {item['name']}{qty}") + if sl["checked_items"]: + lines.append( + f" ({len(sl['checked_items'])} items already checked off)" + ) + return "\n".join(lines) + except Exception as e: + return f"Error fetching shopping lists: {str(e)}" + + +@tool +async def mealie_create_meal_plan( + date: str, + entry_type: str = "dinner", + recipe_slug: str = "", + title: str = "", + note: str = "", +) -> str: + """Create a new meal plan entry in Mealie. + + Use this tool when the user wants to: + - Add a meal to the meal plan + - Plan dinner/lunch/breakfast for a specific day + - Schedule a recipe for a date + + Args: + date: Date in YYYY-MM-DD format + entry_type: Type of meal - breakfast, lunch, dinner, or side (default: dinner) + recipe_slug: Recipe slug to add (optional) + title: Custom title if no recipe (optional) + note: Additional notes (optional) + + Returns: + Confirmation of the created meal plan entry. + """ + if not mealie_enabled: + return "Mealie integration is not configured. Please set MEALIE_BASE_URL and MEALIE_API_TOKEN environment variables." + + try: + result = await mealie_service.create_meal_plan( + date=date, + entry_type=entry_type, + recipe_slug=recipe_slug or None, + title=title or None, + note=note or None, + ) + name = result.get("recipe_name") or result.get("title") or "meal" + return f"Added {result['entry_type']} to {result['date']}: {name}" + except Exception as e: + return f"Error creating meal plan: {str(e)}" + + @tool async def obsidian_search_notes(query: str) -> str: """Search through Obsidian vault notes for information. @@ -798,6 +1024,16 @@ if ynab_enabled: ynab_insights, ] ) +if mealie_enabled: + tools.extend( + [ + mealie_todays_meals, + mealie_meal_plans, + mealie_get_recipe, + mealie_shopping_lists, + mealie_create_meal_plan, + ] + ) if obsidian_enabled: tools.extend( [ diff --git a/blueprints/conversation/prompts.py b/blueprints/conversation/prompts.py index 1c9086c..c408a7e 100644 --- a/blueprints/conversation/prompts.py +++ b/blueprints/conversation/prompts.py @@ -40,6 +40,15 @@ You have access to Ryan's budget data through YNAB (You Need A Budget). When use - Use ynab_insights to provide spending trends, patterns, and recommendations Always use these tools when asked about budgets, spending, transactions, or financial health. +MEAL PLANNING (Mealie Integration): +You have access to Ryan's meal plans, recipes, and shopping lists through Mealie. When users ask about food or meal planning, use the appropriate Mealie tools: +- Use mealie_todays_meals to check what's planned for today +- Use mealie_meal_plans to see the week's meal schedule or a custom date range +- Use mealie_get_recipe to get full recipe details (ingredients, instructions) +- Use mealie_shopping_lists to check what groceries are needed +- Use mealie_create_meal_plan to add meals to the plan +Always use these tools when asked about meals, recipes, what's for dinner, or shopping lists. + NOTES & RESEARCH (Obsidian Integration): You have access to Ryan's Obsidian vault through the Obsidian integration. When users ask about research, personal notes, or information that might be stored in markdown files, use the appropriate Obsidian tools: - Use obsidian_search_notes to search through your vault for relevant information diff --git a/docker-compose.yml b/docker-compose.yml index b7fcd2c..4efb79b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,8 @@ services: - TAVILY_API_KEY=${TAVILIY_API_KEY} - YNAB_ACCESS_TOKEN=${YNAB_ACCESS_TOKEN} - YNAB_BUDGET_ID=${YNAB_BUDGET_ID} + - MEALIE_BASE_URL=${MEALIE_BASE_URL} + - MEALIE_API_TOKEN=${MEALIE_API_TOKEN} - TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID} - TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN} - TWILIO_WHATSAPP_NUMBER=${TWILIO_WHATSAPP_NUMBER} diff --git a/utils/mealie_service.py b/utils/mealie_service.py new file mode 100644 index 0000000..c3d4e96 --- /dev/null +++ b/utils/mealie_service.py @@ -0,0 +1,529 @@ +"""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