summaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
Diffstat (limited to 'services')
-rw-r--r--services/__init__.py0
-rw-r--r--services/ai_service.py158
-rw-r--r--services/pantry_service.py51
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,
+ }