diff options
Diffstat (limited to 'services')
| -rw-r--r-- | services/__init__.py | 0 | ||||
| -rw-r--r-- | services/ai_service.py | 158 | ||||
| -rw-r--r-- | services/pantry_service.py | 51 |
3 files changed, 209 insertions, 0 deletions
diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/services/__init__.py 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) diff --git a/services/pantry_service.py b/services/pantry_service.py new file mode 100644 index 0000000..19271e5 --- /dev/null +++ b/services/pantry_service.py @@ -0,0 +1,51 @@ +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from models import Ingredient, MealLog +from collections import Counter + + +def build_pantry_context(db: Session) -> dict: + """Return a dict with pantry state for use as AI context.""" + today = datetime.utcnow().date() + soon = today + timedelta(days=3) + + # All ingredients + all_ingredients = db.query(Ingredient).all() + available = [ + { + "name": i.name, + "quantity": i.quantity, + "unit": i.unit, + "category": i.category, + "expiry": i.expiry_date.isoformat() if i.expiry_date else None, + } + for i in all_ingredients + ] + + # Expiring within 3 days + expiring = [ + i for i in available + if i["expiry"] and i["expiry"] <= soon.isoformat() + ] + + # Recent meals (last 14 days) + cutoff = datetime.utcnow() - timedelta(days=14) + recent_meal_logs = db.query(MealLog).filter(MealLog.eaten_at >= cutoff).order_by(MealLog.eaten_at.desc()).all() + recent_meals = [ + { + "date": m.eaten_at.date().isoformat(), + "meal_type": m.meal_type, + "meal_name": m.meal_name, + } + for m in recent_meal_logs + ] + + # Meal frequency (count by name) + meal_frequency = dict(Counter(m["meal_name"] for m in recent_meals)) + + return { + "available_ingredients": available, + "expiring_soon": expiring, + "recent_meals": recent_meals, + "meal_frequency": meal_frequency, + } |
