diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-08 01:58:48 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-08 01:58:48 -0700 |
| commit | 2e0a94e88c847a5ed8dc6ad5fa49715cd631bdfe (patch) | |
| tree | 0c27fc5a8d8cbba60e571bb6690a13c0c0060ff4 /services/ai_service.py | |
Initial commit — Commis personal chef app
AI-powered local chef tool: pantry tracking, meal logging, rotating weekly
menu generation, and grocery list optimization via Ollama (llama3).
FastAPI backend, SQLite, vanilla JS frontend.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'services/ai_service.py')
| -rw-r--r-- | services/ai_service.py | 158 |
1 files changed, 158 insertions, 0 deletions
diff --git a/services/ai_service.py b/services/ai_service.py new file mode 100644 index 0000000..2efd38f --- /dev/null +++ b/services/ai_service.py @@ -0,0 +1,158 @@ +import ollama +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: + client = _get_client() + response = client.chat( + model=settings.model_name, + messages=messages, + format="json", + options={"temperature": 0.7, "num_predict": 4096}, + ) + return response["message"]["content"] + + +async def _chat(messages: list) -> dict: + loop = asyncio.get_event_loop() + raw = await loop.run_in_executor(None, lambda: _chat_sync(messages)) + try: + return json.loads(raw) + except json.JSONDecodeError as e: + 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. + +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 + +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": {{ ... }} + }}, + "notes": "brief explanation of choices" +}}""" + + messages = [ + {"role": "system", "content": 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. + +MEAL PLAN: +{json.dumps(menu_plan, 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) +- 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 + +Respond ONLY with this JSON: +{{ + "items": [ + {{ + "name": "...", + "quantity": 2.0, + "unit": "lbs", + "store_section": "produce", + "estimated_cost": 3.50, + "used_in_meals": ["Chicken Stir Fry", "Chicken Soup"], + "reason": "not in pantry" + }} + ], + "total_estimate": 45.00, + "shopping_notes": "..." +}}""" + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message}, + ] + + return await _chat(messages) + + +async def suggest_recipe(pantry_context: dict) -> dict: + """Suggest one recipe using available pantry ingredients.""" + user_message = f"""Suggest ONE recipe I can make right now using only these pantry ingredients (plus common staples). + +PANTRY: +{json.dumps(pantry_context["available_ingredients"], indent=2)} + +Respond ONLY with this JSON: +{{ + "recipe": {{ + "name": "...", + "meal_type": "dinner", + "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}], + "instructions": "Step 1... Step 2...", + "time_minutes": 30, + "serves": 2 + }} +}}""" + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message}, + ] + + return await _chat(messages) + + +async def get_available_models() -> list: + """Get list of available Ollama model names.""" + def _list_models_sync(): + client = _get_client() + try: + models_resp = client.list() + return [m["name"] for m in models_resp.get("models", [])] + except Exception: + return [] + + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _list_models_sync) |
