summaryrefslogtreecommitdiff
path: root/services/ai_service.py
blob: 4b54c15a6e6796ef4ad35f27701ccc241627f75e (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
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"""Given my current pantry and recent meal history, suggest 8-12 diverse recipes I could make this week.

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

GUIDELINES:
- Suggest genuinely good, practical dishes — do NOT limit yourself to only pantry ingredients
- Use pantry ingredients where it makes sense, but freely include recipes that require buying additional items
- Avoid meals eaten in the last 7 days (see recent_meals)
- Include a mix of breakfast, lunch, and dinner options
- Keep each recipe 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"}}],
      "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"
}}"""

    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"""Given these recipes and current pantry, generate a minimal grocery list.

RECIPES:
{json.dumps(recipes, 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 recipes (buy once)
- Group items by store section (produce, dairy, protein, pantry, frozen, bakery)
- Estimate realistic retail costs in USD
- Prefer bulk when ingredient appears in 3+ recipes

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"""Given my current pantry, generate a practical grocery list to stock up for the week.

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

RULES:
- Suggest ingredients that would complement what's already in the pantry
- Group items by store section (produce, dairy, protein, pantry, frozen, bakery)
- Estimate realistic retail costs in USD
- Focus on versatile staples that enable multiple meals

Respond ONLY with this JSON:
{{
  "items": [
    {{
      "name": "...",
      "quantity": 2.0,
      "unit": "lbs",
      "store_section": "produce",
      "estimated_cost": 3.50,
      "used_in_meals": [],
      "reason": "versatile staple"
    }}
  ],
  "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:
- Suggest a genuinely good, practical {meal_type} dish
- Do NOT limit yourself to only pantry ingredients
- 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, a friendly and knowledgeable personal chef assistant. You have full visibility into the user's kitchen — their pantry, this week's recipe suggestions, and their grocery list. Use this context to give personalized, practical cooking advice.

PANTRY (ingredients 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 RECIPE SUGGESTIONS:
{json.dumps(menu_context, indent=2)}

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

Be conversational, helpful, and concise. Reference specific ingredients and recipes by name when relevant. 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
- Do NOT limit yourself to only pantry ingredients
- 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)