summaryrefslogtreecommitdiff
path: root/services/ai_service.py
blob: 2efd38f09c0d1ffb67309bc27d5ede1c144b3182 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
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)