summaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-08 03:24:36 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-08 03:24:36 -0700
commit3de7c5eed5ba262abf0d746211e33800db6d66df (patch)
tree6fddb5381fb178423eac34894add5b611babe300 /services
parentf361e7599d9a11ad3397b7b6bffee151ab9bdde9 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'services')
-rw-r--r--services/ai_service.py201
1 files changed, 155 insertions, 46 deletions
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))