From 3de7c5eed5ba262abf0d746211e33800db6d66df Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Fri, 8 May 2026 03:24:36 -0700 Subject: Add recipe suggestions, chat tab, and major workflow improvements - Replace 7-day grid menu with browsable recipe suggestion cards (swap, remove, make this) - Add Chat tab: conversational AI with full pantry/menu/grocery context - Grocery list works without a menu (pantry-only mode) - Individual grocery checkboxes auto-add items to pantry - Swap modal with optional preference input - User notes textarea on menu and grocery generation - Clear button for menu and grocery list - LLM notes/summary displayed after generation, persisted to DB - Favicon linked in HTML - Category dropdown styled for dark theme - System prompt configurable via SYSTEM_PROMPT in .env - Fix startup error (ollama_timeout default), DB migration for menu_plans.notes - Simplify: batch N+1 queries, extract _current_monday(), merge chat sync fns, asyncio.get_running_loop(), fix currentGroceryId bug, cap chat history Co-Authored-By: Claude Sonnet 4.6 --- services/ai_service.py | 201 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 155 insertions(+), 46 deletions(-) (limited to 'services/ai_service.py') diff --git a/services/ai_service.py b/services/ai_service.py index 2efd38f..0abad09 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -3,30 +3,24 @@ import json import asyncio from config import settings -SYSTEM_PROMPT = """You are a professional chef assistant. You MUST respond with valid JSON only — no markdown, no explanation outside the JSON. You prioritize: -1. Using ingredients that expire soonest -2. Nutritional variety across the week -3. Avoiding meals eaten in the past 7 days -4. Practical home recipes under 60 minutes""" - - def _get_client(): return ollama.Client(host=settings.ollama_host) -def _chat_sync(messages: list) -> str: +def _chat_sync(messages: list, json_format: bool = True) -> str: client = _get_client() + kwargs = {"temperature": 0.7, "num_predict": 4096 if json_format else 1024} response = client.chat( model=settings.model_name, messages=messages, - format="json", - options={"temperature": 0.7, "num_predict": 4096}, + **({"format": "json"} if json_format else {}), + options=kwargs, ) return response["message"]["content"] async def _chat(messages: list) -> dict: - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() raw = await loop.run_in_executor(None, lambda: _chat_sync(messages)) try: return json.loads(raw) @@ -34,63 +28,63 @@ async def _chat(messages: list) -> dict: raise ValueError(f"Ollama returned invalid JSON: {e}. Raw: {raw[:200]}") -async def generate_weekly_menu(pantry_context: dict) -> dict: - """Generate a 7-day rotating meal plan based on pantry state.""" - user_message = f"""Given the following pantry and recent meal history, generate a 7-day rotating meal plan. +async def generate_weekly_menu(pantry_context: dict, user_notes: str | None = None) -> dict: + """Suggest 8-12 diverse recipes for the week, prioritizing expiring pantry items.""" + user_message = f"""Given my current pantry and recent meal history, suggest 8-12 diverse recipes I could make this week. PANTRY STATE: {json.dumps(pantry_context, indent=2)} -RULES: -- Each day needs breakfast, lunch, and dinner -- No meal should repeat within the same week -- No meal should repeat a meal eaten in the last 7 days (see recent_meals) -- Prioritize ingredients expiring within 3 days (see expiring_soon) -- Each recipe should only use pantry ingredients OR common staples (salt, pepper, oil, etc.) -- Keep each meal practical — under 60 minutes +GUIDELINES: +- Suggest genuinely good, practical dishes — do NOT limit yourself to only pantry ingredients +- Prioritize using ingredients expiring soon (see expiring_soon), but feel free to include recipes that require buying additional items +- Avoid meals eaten in the last 7 days (see recent_meals) +- Include a mix of breakfast, lunch, and dinner options +- Keep each recipe under 60 minutes +- Include full ingredient lists with quantities and units Respond ONLY with this JSON structure: {{ - "week_plan": {{ - "monday": {{ - "breakfast": {{"name": "...", "ingredients": ["item1", "item2"], "instructions": "...", "time_minutes": 20}}, - "lunch": {{"name": "...", "ingredients": [...], "instructions": "...", "time_minutes": 30}}, - "dinner": {{"name": "...", "ingredients": [...], "instructions": "...", "time_minutes": 45}} - }}, - "tuesday": {{ ... }}, - "wednesday": {{ ... }}, - "thursday": {{ ... }}, - "friday": {{ ... }}, - "saturday": {{ ... }}, - "sunday": {{ ... }} - }}, + "recipes": [ + {{ + "name": "...", + "meal_type": "breakfast", + "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}], + "instructions": "Step 1... Step 2...", + "time_minutes": 20, + "serves": 2 + }} + ], "notes": "brief explanation of choices" }}""" + if user_notes: + user_message += f"\n\nUSER PREFERENCES:\n{user_notes}" + messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": settings.system_prompt}, {"role": "user", "content": user_message}, ] return await _chat(messages) -async def generate_grocery_list(menu_plan: dict, pantry_context: dict) -> dict: - """Generate a minimal grocery list based on the menu plan and current pantry.""" - user_message = f"""Given the weekly meal plan and current pantry, generate a minimal grocery list. +async def generate_grocery_list(recipes: list, pantry_context: dict, user_notes: str | None = None) -> dict: + """Generate a minimal grocery list based on a list of recipes and current pantry.""" + user_message = f"""Given these recipes and current pantry, generate a minimal grocery list. -MEAL PLAN: -{json.dumps(menu_plan, indent=2)} +RECIPES: +{json.dumps(recipes, indent=2)} CURRENT PANTRY: {json.dumps(pantry_context["available_ingredients"], indent=2)} RULES: - Only list ingredients NOT already sufficiently in the pantry -- Consolidate duplicate ingredients across meals (buy once for the week) +- Consolidate duplicate ingredients across recipes (buy once) - Group items by store section (produce, dairy, protein, pantry, frozen, bakery) - Estimate realistic retail costs in USD -- Prefer bulk when ingredient appears in 3+ meals +- Prefer bulk when ingredient appears in 3+ recipes Respond ONLY with this JSON: {{ @@ -101,7 +95,7 @@ Respond ONLY with this JSON: "unit": "lbs", "store_section": "produce", "estimated_cost": 3.50, - "used_in_meals": ["Chicken Stir Fry", "Chicken Soup"], + "used_in_meals": ["Recipe A", "Recipe B"], "reason": "not in pantry" }} ], @@ -109,8 +103,55 @@ Respond ONLY with this JSON: "shopping_notes": "..." }}""" + if user_notes: + user_message += f"\n\nUSER PREFERENCES:\n{user_notes}" + + messages = [ + {"role": "system", "content": settings.system_prompt}, + {"role": "user", "content": user_message}, + ] + + return await _chat(messages) + + +async def generate_grocery_list_from_pantry(pantry_context: dict, user_notes: str | None = None) -> dict: + """Generate a grocery list based on pantry state alone, without a menu plan.""" + user_message = f"""Given my current pantry, generate a practical grocery list to stock up for the week. + +CURRENT PANTRY: +{json.dumps(pantry_context["available_ingredients"], indent=2)} + +EXPIRING SOON: +{json.dumps(pantry_context.get("expiring_soon", []), indent=2)} + +RULES: +- Suggest ingredients that would complement what's already in the pantry +- Group items by store section (produce, dairy, protein, pantry, frozen, bakery) +- Estimate realistic retail costs in USD +- Focus on versatile staples that enable multiple meals + +Respond ONLY with this JSON: +{{ + "items": [ + {{ + "name": "...", + "quantity": 2.0, + "unit": "lbs", + "store_section": "produce", + "estimated_cost": 3.50, + "used_in_meals": [], + "reason": "versatile staple" + }} + ], + "total_estimate": 45.00, + "shopping_notes": "..." +}}""" + + if user_notes: + user_message += f"\n\nUSER PREFERENCES:\n{user_notes}" + messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": settings.system_prompt}, {"role": "user", "content": user_message}, ] @@ -137,7 +178,46 @@ Respond ONLY with this JSON: }}""" messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": settings.system_prompt}, + {"role": "user", "content": user_message}, + ] + + return await _chat(messages) + + +async def generate_replacement_recipe(meal_type: str, existing_names: list, pantry_context: dict, user_notes: str | None = None) -> dict: + """Generate a single replacement recipe of the given meal type.""" + user_message = f"""Suggest ONE {meal_type} recipe to replace a dish I don't want. + +CURRENT PANTRY: +{json.dumps(pantry_context["available_ingredients"], indent=2)} + +ALREADY IN PLAN (do not suggest these): +{json.dumps(existing_names, indent=2)} + +GUIDELINES: +- Suggest a genuinely good, practical {meal_type} dish +- Do NOT limit yourself to only pantry ingredients +- Under 60 minutes +- Must not be any of the dishes already in the plan + +Respond ONLY with this JSON: +{{ + "recipe": {{ + "name": "...", + "meal_type": "{meal_type}", + "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}], + "instructions": "Step 1... Step 2...", + "time_minutes": 30, + "serves": 2 + }} +}}""" + + if user_notes: + user_message += f"\n\nUSER PREFERENCES:\n{user_notes}" + + messages = [ + {"role": "system", "content": settings.system_prompt}, {"role": "user", "content": user_message}, ] @@ -154,5 +234,34 @@ async def get_available_models() -> list: except Exception: return [] - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() return await loop.run_in_executor(None, _list_models_sync) + + +async def chat_with_commis(message: str, history: list, pantry_context: dict, menu_context: list, grocery_context: list) -> str: + """Have a conversation with Commis using full kitchen context.""" + system_prompt = f"""You are Commis, a friendly and knowledgeable personal chef assistant. You have full visibility into the user's kitchen — their pantry, this week's recipe suggestions, and their grocery list. Use this context to give personalized, practical cooking advice. + +PANTRY (ingredients on hand): +{json.dumps(pantry_context.get("available_ingredients", []), indent=2)} + +EXPIRING SOON: +{json.dumps(pantry_context.get("expiring_soon", []), indent=2)} + +RECENT MEALS (last 14 days): +{json.dumps(pantry_context.get("recent_meals", []), indent=2)} + +THIS WEEK'S RECIPE SUGGESTIONS: +{json.dumps(menu_context, indent=2)} + +GROCERY LIST: +{json.dumps(grocery_context, indent=2)} + +Be conversational, helpful, and concise. Reference specific ingredients and recipes by name when relevant. You do not need to respond with JSON.""" + + messages = [{"role": "system", "content": system_prompt}] + messages.extend(history) + messages.append({"role": "user", "content": message}) + + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda: _chat_sync(messages, json_format=False)) -- cgit v1.3-2-g0d8e