summaryrefslogtreecommitdiff
path: root/services/ai_service.py
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-08 01:58:48 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-08 01:58:48 -0700
commit2e0a94e88c847a5ed8dc6ad5fa49715cd631bdfe (patch)
tree0c27fc5a8d8cbba60e571bb6690a13c0c0060ff4 /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.py158
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)