summaryrefslogtreecommitdiff
path: root/services/ai_service.py
blob: 643a9a2d7ce42c4bc6ae943c60d5f57ad1f90eeb (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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
import ollama
import json
import asyncio
from config import settings

def _get_client():
    return ollama.Client(host=settings.ollama_host)


def _chat_sync(messages: list, json_format: bool = True) -> str:
    client = _get_client()
    kwargs = {"temperature": 0.7, "num_predict": 4096 if json_format else 1024}
    response = client.chat(
        model=settings.model_name,
        messages=messages,
        **({"format": "json"} if json_format else {}),
        options=kwargs,
    )
    return response["message"]["content"]


async def _chat(messages: list) -> dict:
    loop = asyncio.get_running_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, user_notes: str | None = None) -> dict:
    """Suggest 8-12 diverse recipes for the week, prioritizing expiring pantry items."""
    user_message = f"""Plan 8-12 diverse recipes for this week that make the most of what I already have.

PANTRY STATE:
{json.dumps(pantry_context, indent=2)}

REQUIREMENTS:
- BUILD recipes around pantry ingredients — most of each recipe should use what I already have
- Each recipe may call for AT MOST 4-5 ingredients not in my pantry (items I'd pick up at the store)
- Do not repeat any meal eaten in the last 7 days (see recent_meals)
- Cover a mix of breakfast, lunch, and dinner
- Every recipe must be completable in under 60 minutes
- Include full ingredient lists with quantities and units

Respond ONLY with this JSON structure:
{{
  "recipes": [
    {{
      "name": "...",
      "meal_type": "breakfast",
      "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup", "in_pantry": true}}],
      "description": "1-2 sentence description of the dish's flavor and appeal",
      "instructions": "1. ... 2. ... (4-8 numbered steps, 1-2 sentences each)",
      "time_minutes": 20,
      "serves": 2
    }}
  ],
  "notes": "brief explanation of choices and what extra ingredients are needed"
}}

Set `in_pantry: true` for each ingredient that matches something in the pantry, `false` for items that need to be purchased."""

    if user_notes:
        user_message += f"\n\nUSER PREFERENCES:\n{user_notes}"

    messages = [
        {"role": "system", "content": settings.system_prompt},
        {"role": "user", "content": user_message},
    ]

    return await _chat(messages)


async def generate_grocery_list(recipes: list, pantry_context: dict, user_notes: str | None = None) -> dict:
    """Generate a minimal grocery list based on a list of recipes and current pantry."""
    user_message = f"""Generate the grocery list of ONLY the items I need to buy to cook all these recipes.

RECIPES:
{json.dumps(recipes, indent=2)}

PANTRY (I already have these — DO NOT include them in the grocery list):
{json.dumps(pantry_context["available_ingredients"], indent=2)}

RULES:
- For every ingredient in every recipe, cross-check against the pantry. If the pantry has it → SKIP IT. If it's not in the pantry → include it.
- The goal is a minimal, accurate list of ONLY what is missing — do not list anything already stocked.
- Consolidate the same ingredient appearing across multiple recipes into one entry (sum the quantities).
- Group items by store section: produce, dairy, protein, pantry, frozen, bakery.
- Estimate realistic retail costs in USD.
- If an ingredient appears in 3+ recipes, note that bulk purchase may save money.

Respond ONLY with this JSON:
{{
  "items": [
    {{
      "name": "...",
      "quantity": 2.0,
      "unit": "lbs",
      "store_section": "produce",
      "estimated_cost": 3.50,
      "used_in_meals": ["Recipe A", "Recipe B"],
      "reason": "not in pantry"
    }}
  ],
  "total_estimate": 45.00,
  "shopping_notes": "..."
}}"""

    if user_notes:
        user_message += f"\n\nUSER PREFERENCES:\n{user_notes}"

    messages = [
        {"role": "system", "content": settings.system_prompt},
        {"role": "user", "content": user_message},
    ]

    return await _chat(messages)


async def generate_grocery_list_from_pantry(pantry_context: dict, user_notes: str | None = None) -> dict:
    """Generate a grocery list based on pantry state alone, without a menu plan."""
    user_message = f"""Generate a solid weekly grocery list to set me up for a full week of cooking.

CURRENT PANTRY:
{json.dumps(pantry_context["available_ingredients"], indent=2)}

RULES:
- If the pantry is empty or nearly empty, suggest a complete foundational shopping list: proteins, produce, dairy, grains, and pantry staples for a week of balanced meals.
- If the pantry has some items, build around what's there — prioritize ingredients that complement existing stock and fill obvious gaps.
- Do NOT list items already in the pantry.
- Aim for variety: at least 2 proteins, a range of vegetables and fruits, a grain, and pantry staples.
- Group items by store section: produce, dairy, protein, pantry, frozen, bakery.
- Estimate realistic retail costs in USD.

Respond ONLY with this JSON:
{{
  "items": [
    {{
      "name": "...",
      "quantity": 2.0,
      "unit": "lbs",
      "store_section": "produce",
      "estimated_cost": 3.50,
      "used_in_meals": [],
      "reason": "weekly staple / fills pantry gap"
    }}
  ],
  "total_estimate": 45.00,
  "shopping_notes": "..."
}}"""

    if user_notes:
        user_message += f"\n\nUSER PREFERENCES:\n{user_notes}"

    messages = [
        {"role": "system", "content": settings.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": settings.system_prompt},
        {"role": "user", "content": user_message},
    ]

    return await _chat(messages)


async def generate_replacement_recipe(meal_type: str, existing_names: list, pantry_context: dict, user_notes: str | None = None) -> dict:
    """Generate a single replacement recipe of the given meal type."""
    user_message = f"""Suggest ONE {meal_type} recipe to replace a dish I don't want.

CURRENT PANTRY:
{json.dumps(pantry_context["available_ingredients"], indent=2)}

ALREADY IN PLAN (do not suggest these):
{json.dumps(existing_names, indent=2)}

GUIDELINES:
- Prefer a recipe that uses pantry ingredients as much as possible — only a few new items to buy
- Make it a genuinely good, practical {meal_type} dish
- Under 60 minutes
- Must not be any of the dishes already in the plan

Respond ONLY with this JSON:
{{
  "recipe": {{
    "name": "...",
    "meal_type": "{meal_type}",
    "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}],
    "description": "1-2 sentence description of the dish's flavor and appeal",
    "instructions": "1. ... 2. ... (4-8 numbered steps, 1-2 sentences each)",
    "time_minutes": 30,
    "serves": 2
  }}
}}"""

    if user_notes:
        user_message += f"\n\nUSER PREFERENCES:\n{user_notes}"

    messages = [
        {"role": "system", "content": settings.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_running_loop()
    return await loop.run_in_executor(None, _list_models_sync)


async def chat_with_commis(message: str, history: list, pantry_context: dict, menu_context: list, grocery_context: list) -> str:
    """Have a conversation with Commis using full kitchen context."""
    system_prompt = f"""You are Commis, the user's personal chef assistant. You have real-time visibility into their kitchen — pantry, this week's planned recipes, grocery list, and recent meal history. THIS context is your primary source of truth when answering cooking questions.

PANTRY (ingredients currently on hand):
{json.dumps(pantry_context.get("available_ingredients", []), indent=2)}

EXPIRING SOON:
{json.dumps(pantry_context.get("expiring_soon", []), indent=2)}

RECENT MEALS (last 14 days):
{json.dumps(pantry_context.get("recent_meals", []), indent=2)}

THIS WEEK'S PLANNED RECIPES:
{json.dumps(menu_context, indent=2)}

GROCERY LIST (items to buy):
{json.dumps(grocery_context, indent=2)}

HOW TO RESPOND:
- When the user asks what to cook, suggest from THIS WEEK'S PLANNED RECIPES first — they were chosen for the pantry.
- When the user asks about an ingredient, reference whether it's actually in their pantry.
- When the user asks about substitutions, check if a pantry item can stand in before suggesting something to buy.
- When the user asks about shopping, reference the grocery list.
- Be concise, specific, and practical. Reference actual recipe names and ingredient names from the data above.
- You do not need to respond with JSON."""

    messages = [{"role": "system", "content": system_prompt}]
    messages.extend(history)
    messages.append({"role": "user", "content": message})

    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(None, lambda: _chat_sync(messages, json_format=False))


async def parse_recipe_from_text(text: str) -> dict:
    """Ask Ollama to parse unstructured recipe text into our standard recipe schema."""
    user_message = f"""Parse this recipe text and extract structured data.

RECIPE TEXT:
{text[:4000]}

Respond ONLY with this JSON:
{{
  "recipe": {{
    "name": "...",
    "meal_type": "dinner",
    "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}],
    "description": "1-2 sentence description of the dish's flavor and appeal",
    "instructions": "1. ... 2. ... (4-8 numbered steps, 1-2 sentences each)",
    "time_minutes": 30,
    "serves": 2
  }}
}}
meal_type must be one of: breakfast, lunch, dinner, snack"""

    messages = [
        {"role": "system", "content": settings.system_prompt},
        {"role": "user", "content": user_message},
    ]
    return await _chat(messages)


async def generate_single_recipe(description: str, existing_names: list, pantry_context: dict) -> dict:
    """Generate one recipe matching a user description."""
    user_message = f"""Suggest ONE recipe based on this description: "{description}"

CURRENT PANTRY:
{json.dumps(pantry_context["available_ingredients"], indent=2)}

ALREADY IN PLAN (do not suggest these):
{json.dumps(existing_names, indent=2)}

GUIDELINES:
- Match the description as closely as possible
- Prefer ingredients already in the pantry — only a few new items to buy if needed
- Under 60 minutes
- Must not be any dish already in the plan

Respond ONLY with this JSON:
{{
  "recipe": {{
    "name": "...",
    "description": "1-2 sentence description of the dish's flavor and appeal",
    "meal_type": "dinner",
    "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}],
    "instructions": "1. ... 2. ... (4-8 numbered steps, 1-2 sentences each)",
    "time_minutes": 30,
    "serves": 2
  }}
}}
meal_type must be one of: breakfast, lunch, dinner, snack"""

    messages = [
        {"role": "system", "content": settings.system_prompt},
        {"role": "user", "content": user_message},
    ]
    return await _chat(messages)