LogicGoInfotechSpaces commited on
Commit
a600a52
·
1 Parent(s): fa10656

Fix bugs and improve budget recommendation system: Add API logging, fix OpenAI integration, enhance budget query patterns, add category extraction from headCategories, improve error handling

Browse files
Files changed (2) hide show
  1. app/main.py +92 -2
  2. app/smart_recommendation.py +273 -45
app/main.py CHANGED
@@ -1,10 +1,11 @@
1
- from fastapi import FastAPI, HTTPException, Depends
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from pymongo import MongoClient
4
  from pymongo.errors import ConnectionFailure
5
  import os
 
6
  from typing import List, Optional
7
- from datetime import datetime, timedelta
8
  from app.models import BudgetRecommendation, Expense, Budget, CategoryExpense
9
  from app.smart_recommendation import SmartBudgetRecommender
10
 
@@ -37,6 +38,95 @@ except ConnectionFailure as e:
37
  # Initialize Smart Budget Recommender
38
  recommender = SmartBudgetRecommender(db)
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  @app.get("/")
41
  async def root():
42
  return {"message": "Smart Budget Recommendation API", "status": "running"}
 
1
+ from fastapi import FastAPI, HTTPException, Depends, Request
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from pymongo import MongoClient
4
  from pymongo.errors import ConnectionFailure
5
  import os
6
+ import time
7
  from typing import List, Optional
8
+ from datetime import datetime, timedelta, timezone
9
  from app.models import BudgetRecommendation, Expense, Budget, CategoryExpense
10
  from app.smart_recommendation import SmartBudgetRecommender
11
 
 
38
  # Initialize Smart Budget Recommender
39
  recommender = SmartBudgetRecommender(db)
40
 
41
+ # IST timezone (UTC+5:30)
42
+ IST = timezone(timedelta(hours=5, minutes=30))
43
+
44
+ def log_api_call(db, name: str, status: str, response_time: float, endpoint: str = None, error: str = None):
45
+ """
46
+ Log API call to MongoDB api_logs collection
47
+
48
+ Args:
49
+ db: MongoDB database instance
50
+ name: API name (e.g., "smart budget recommendation")
51
+ status: "success" or "fail"
52
+ response_time: Response time in seconds
53
+ endpoint: Optional endpoint path
54
+ error: Optional error message
55
+ """
56
+ try:
57
+ # Get current time in IST
58
+ ist_time = datetime.now(IST)
59
+ # Format: DD-MM-YYYY HH:MM:SS:IST
60
+ timestamp_str = ist_time.strftime("%d-%m-%Y %H:%M:%S:IST")
61
+
62
+ # Extract user_id from endpoint (e.g., "/recommendations/user123" -> "user123")
63
+ user_id = None
64
+ if endpoint:
65
+ # Extract user_id from path patterns like "/recommendations/{user_id}" or "/category-expenses/{user_id}"
66
+ parts = endpoint.strip("/").split("/")
67
+ if len(parts) >= 2:
68
+ user_id = parts[1] # Get the user_id part
69
+
70
+ log_entry = {
71
+ "name": name,
72
+ "status": status,
73
+ "date": timestamp_str, # Combined date and time in IST
74
+ "response_time": round(response_time, 3), # Round to 3 decimal places
75
+ "user_id": user_id,
76
+ }
77
+
78
+ if error:
79
+ log_entry["error"] = error
80
+
81
+ # Insert into api_logs collection
82
+ db.api_logs.insert_one(log_entry)
83
+ except Exception as e:
84
+ # Don't fail the API call if logging fails
85
+ print(f"Failed to log API call: {e}")
86
+
87
+ @app.middleware("http")
88
+ async def log_requests(request: Request, call_next):
89
+ """Middleware to log API requests and track response time"""
90
+ start_time = time.time()
91
+
92
+ # Only log specific endpoints
93
+ endpoint = request.url.path
94
+ should_log = endpoint in ["/recommendations", "/category-expenses"] or endpoint.startswith("/recommendations/") or endpoint.startswith("/category-expenses/")
95
+
96
+ if should_log:
97
+ try:
98
+ response = await call_next(request)
99
+ process_time = time.time() - start_time
100
+
101
+ # Determine status
102
+ status = "success" if response.status_code < 400 else "fail"
103
+
104
+ # Log the API call
105
+ log_api_call(
106
+ db=db,
107
+ name="smart budget recommendation",
108
+ status=status,
109
+ response_time=process_time,
110
+ endpoint=endpoint
111
+ )
112
+
113
+ return response
114
+ except Exception as e:
115
+ process_time = time.time() - start_time
116
+ # Log failure
117
+ log_api_call(
118
+ db=db,
119
+ name="smart budget recommendation",
120
+ status="fail",
121
+ response_time=process_time,
122
+ endpoint=endpoint,
123
+ error=str(e)
124
+ )
125
+ raise
126
+ else:
127
+ # For other endpoints, just pass through
128
+ return await call_next(request)
129
+
130
  @app.get("/")
131
  async def root():
132
  return {"message": "Smart Budget Recommendation API", "status": "running"}
app/smart_recommendation.py CHANGED
@@ -69,17 +69,22 @@ class SmartBudgetRecommender:
69
  avg_expense = data["average_monthly"]
70
  confidence = self._calculate_confidence(data)
71
 
72
- # 1) Try OpenAI first (primary source of recommendation)
73
  ai_result = self._get_ai_recommendation(category, data, avg_expense)
74
- if ai_result:
75
  recommended_budget = ai_result.get("recommended_budget")
76
- reason = ai_result.get("reason")
77
  action = ai_result.get("action")
 
78
  else:
79
- # 2) Fallback to rule-based recommendation
80
  recommended_budget = self._calculate_recommended_budget(avg_expense, data)
81
  reason = self._generate_reason(category, avg_expense, recommended_budget)
82
  action = None
 
 
 
 
83
 
84
  recommendations.append(BudgetRecommendation(
85
  category=category,
@@ -109,8 +114,18 @@ class SmartBudgetRecommender:
109
  amount = expense.get("amount", 0)
110
  date = expense.get("date")
111
 
 
 
 
 
112
  if isinstance(date, str):
113
- date = datetime.fromisoformat(date.replace('Z', '+00:00'))
 
 
 
 
 
 
114
 
115
  category_data[category]["total"] += amount
116
  category_data[category]["count"] += 1
@@ -247,6 +262,29 @@ class SmartBudgetRecommender:
247
  result.sort(key=lambda x: x.average_monthly_expense, reverse=True)
248
  return result
249
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  def _get_category_stats_from_budgets(
251
  self, user_id: str, month: int, year: int
252
  ) -> Dict:
@@ -255,53 +293,236 @@ class SmartBudgetRecommender:
255
 
256
  We treat each budget document (e.g. \"Office Maintenance\", \"LOGICGO\")
257
  as a spending category and derive an \"average\" from its amounts.
 
258
  """
259
- # createdBy is stored as ObjectId in WalletSync, while user_id is a string.
260
- # Try to cast to ObjectId; if it fails, fall back to matching the raw string.
261
- query: Dict = {"status": "OPEN"}
 
 
 
262
  try:
263
- query["createdBy"] = ObjectId(user_id)
264
- except Exception:
265
- query["createdBy"] = user_id
266
-
267
- budgets = list(self.db.budgets.find(query))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
 
269
  if not budgets:
 
 
 
 
 
 
 
270
  return {}
271
 
 
 
272
  result: Dict[str, Dict] = {}
273
  for b in budgets:
274
- # Use budget \"name\" as category label
275
- category = b.get("name", "Uncategorized")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
  # Derive a base amount from WalletSync fields
278
- max_amount = float(b.get("maxAmount", 0) or 0)
279
- spend_amount = float(b.get("spendAmount", 0) or 0)
280
-
281
- # If there is recorded spend, use that as \"average\"; otherwise maxAmount
282
- base_amount = spend_amount if spend_amount > 0 else max_amount
283
- if base_amount <= 0:
284
- continue
 
285
 
286
- if category not in result:
287
- result[category] = {
288
- "average_monthly": base_amount,
289
- "total": base_amount,
290
- "count": 1,
291
- "months_analyzed": 1,
292
- "std_dev": 0.0,
293
- "monthly_values": [base_amount],
294
- }
295
  else:
296
- # If multiple budgets per category, average them
297
- result[category]["total"] += base_amount
298
- result[category]["count"] += 1
299
- result[category]["months_analyzed"] = result[category]["count"]
300
- result[category]["average_monthly"] = (
301
- result[category]["total"] / result[category]["count"]
302
- )
303
- result[category]["monthly_values"].append(base_amount)
304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  return result
306
 
307
  def _get_ai_recommendation(self, category: str, data: Dict, avg_expense: float):
@@ -309,7 +530,12 @@ class SmartBudgetRecommender:
309
  if not OPENAI_API_KEY:
310
  return None
311
 
312
- history = ", ".join(f"{value:.0f}" for value in data["monthly_values"])
 
 
 
 
 
313
  summary = (
314
  f"Category: {category}\n"
315
  f"Monthly totals: [{history}]\n"
@@ -330,22 +556,24 @@ class SmartBudgetRecommender:
330
 
331
  try:
332
  response = requests.post(
333
- "https://api.openai.com/v1/responses",
334
  headers={
335
  "Authorization": f"Bearer {OPENAI_API_KEY}",
336
  "Content-Type": "application/json",
337
  },
338
  json={
339
- "model": "gpt-4.1-mini",
340
- "input": prompt,
 
 
341
  "temperature": 0.1,
342
  "response_format": {"type": "json_object"},
343
  },
344
  timeout=30,
345
  )
346
  response.raise_for_status()
347
- data = response.json()
348
- content = data["output"][0]["content"][0]["text"]
349
  return json.loads(content)
350
  except Exception as exc:
351
  print(f"OpenAI recommendation error for {category}: {exc}")
 
69
  avg_expense = data["average_monthly"]
70
  confidence = self._calculate_confidence(data)
71
 
72
+ # Always try OpenAI first (primary source of recommendation)
73
  ai_result = self._get_ai_recommendation(category, data, avg_expense)
74
+ if ai_result and ai_result.get("recommended_budget"):
75
  recommended_budget = ai_result.get("recommended_budget")
76
+ reason = ai_result.get("reason", f"AI recommendation for {category}")
77
  action = ai_result.get("action")
78
+ print(f"OpenAI recommendation for {category}: {recommended_budget}")
79
  else:
80
+ # Fallback to rule-based recommendation if OpenAI fails
81
  recommended_budget = self._calculate_recommended_budget(avg_expense, data)
82
  reason = self._generate_reason(category, avg_expense, recommended_budget)
83
  action = None
84
+ if not ai_result:
85
+ print(f"OpenAI unavailable, using rule-based for {category}: {recommended_budget}")
86
+ else:
87
+ print(f"OpenAI returned invalid data, using rule-based for {category}: {recommended_budget}")
88
 
89
  recommendations.append(BudgetRecommendation(
90
  category=category,
 
114
  amount = expense.get("amount", 0)
115
  date = expense.get("date")
116
 
117
+ # Handle date conversion - skip if date is None or invalid
118
+ if date is None:
119
+ continue
120
+
121
  if isinstance(date, str):
122
+ try:
123
+ date = datetime.fromisoformat(date.replace('Z', '+00:00'))
124
+ except (ValueError, AttributeError):
125
+ continue
126
+ elif not isinstance(date, datetime):
127
+ # If date is not a string or datetime, skip this expense
128
+ continue
129
 
130
  category_data[category]["total"] += amount
131
  category_data[category]["count"] += 1
 
262
  result.sort(key=lambda x: x.average_monthly_expense, reverse=True)
263
  return result
264
 
265
+ def _get_category_name(self, category_id) -> str:
266
+ """Look up category name from categories collection"""
267
+ if not category_id:
268
+ return "Uncategorized"
269
+
270
+ try:
271
+ # Try to find category in categories collection
272
+ if isinstance(category_id, ObjectId):
273
+ category_doc = self.db.categories.find_one({"_id": category_id})
274
+ else:
275
+ try:
276
+ category_doc = self.db.categories.find_one({"_id": ObjectId(category_id)})
277
+ except:
278
+ category_doc = self.db.categories.find_one({"_id": category_id})
279
+
280
+ if category_doc:
281
+ return category_doc.get("name") or category_doc.get("title") or str(category_id)
282
+ except Exception as e:
283
+ print(f"Error looking up category name for {category_id}: {e}")
284
+ pass
285
+
286
+ return str(category_id) if category_id else "Uncategorized"
287
+
288
  def _get_category_stats_from_budgets(
289
  self, user_id: str, month: int, year: int
290
  ) -> Dict:
 
293
 
294
  We treat each budget document (e.g. \"Office Maintenance\", \"LOGICGO\")
295
  as a spending category and derive an \"average\" from its amounts.
296
+ Also extracts categories from headCategories array.
297
  """
298
+ budgets = []
299
+
300
+ print(f"Searching for budgets with user_id: {user_id} (type: {type(user_id).__name__})")
301
+
302
+ # Try multiple query patterns to find budgets (include both OPEN and CLOSE status)
303
+ # Pattern 1: Try with ObjectId (most common in WalletSync) - no status filter
304
  try:
305
+ query_objid = {"createdBy": ObjectId(user_id)}
306
+ budgets_objid = list(self.db.budgets.find(query_objid))
307
+ print(f"Pattern 1 (createdBy ObjectId): Found {len(budgets_objid)} budgets")
308
+ if budgets_objid:
309
+ budgets.extend(budgets_objid)
310
+ except (ValueError, TypeError) as e:
311
+ print(f"Pattern 1 failed: {e}")
312
+ pass
313
+
314
+ # Pattern 2: Try with string user_id - no status filter
315
+ try:
316
+ query_str = {"createdBy": user_id}
317
+ budgets_str = list(self.db.budgets.find(query_str))
318
+ print(f"Pattern 2 (createdBy string): Found {len(budgets_str)} budgets")
319
+ if budgets_str:
320
+ budgets.extend(budgets_str)
321
+ except Exception as e:
322
+ print(f"Pattern 2 failed: {e}")
323
+ pass
324
+
325
+ # Pattern 3: Try with user_id field (alternative field name) - no status filter
326
+ try:
327
+ query_userid = {"user_id": user_id}
328
+ budgets_userid = list(self.db.budgets.find(query_userid))
329
+ print(f"Pattern 3 (user_id string): Found {len(budgets_userid)} budgets")
330
+ if budgets_userid:
331
+ budgets.extend(budgets_userid)
332
+ except Exception as e:
333
+ print(f"Pattern 3 failed: {e}")
334
+ pass
335
+
336
+ # Pattern 4: Try ObjectId with user_id field - no status filter
337
+ try:
338
+ query_objid_userid = {"user_id": ObjectId(user_id)}
339
+ budgets_objid_userid = list(self.db.budgets.find(query_objid_userid))
340
+ print(f"Pattern 4 (user_id ObjectId): Found {len(budgets_objid_userid)} budgets")
341
+ if budgets_objid_userid:
342
+ budgets.extend(budgets_objid_userid)
343
+ except (ValueError, TypeError) as e:
344
+ print(f"Pattern 4 failed: {e}")
345
+ pass
346
+
347
+ # Pattern 5: Check if user_id is actually a budget _id, then get createdBy from it
348
+ try:
349
+ budget_by_id = self.db.budgets.find_one({"_id": ObjectId(user_id)})
350
+ if budget_by_id:
351
+ print(f"Pattern 5: user_id is a budget _id, found budget: {budget_by_id.get('name', 'Unknown')}")
352
+ created_by = budget_by_id.get("createdBy")
353
+ if created_by:
354
+ # Now find all budgets for this createdBy
355
+ query_by_creator = {"createdBy": created_by}
356
+ budgets_by_creator = list(self.db.budgets.find(query_by_creator))
357
+ print(f"Pattern 5: Found {len(budgets_by_creator)} budgets for createdBy: {created_by}")
358
+ if budgets_by_creator:
359
+ budgets.extend(budgets_by_creator)
360
+ except (ValueError, TypeError) as e:
361
+ print(f"Pattern 5 failed: {e}")
362
+ pass
363
+
364
+ # Pattern 6: Try finding by budget _id as string
365
+ try:
366
+ budget_by_id_str = self.db.budgets.find_one({"_id": user_id})
367
+ if budget_by_id_str:
368
+ print(f"Pattern 6: Found budget by _id as string")
369
+ budgets.append(budget_by_id_str)
370
+ except Exception as e:
371
+ print(f"Pattern 6 failed: {e}")
372
+ pass
373
+
374
+ # Remove duplicates based on _id
375
+ seen_ids = set()
376
+ unique_budgets = []
377
+ for b in budgets:
378
+ budget_id = str(b.get("_id", ""))
379
+ if budget_id not in seen_ids:
380
+ seen_ids.add(budget_id)
381
+ unique_budgets.append(b)
382
+
383
+ budgets = unique_budgets
384
 
385
  if not budgets:
386
+ print(f"No budgets found for user_id: {user_id}")
387
+ print(f"Tried all query patterns. Checking sample budget structure...")
388
+ # Get a sample budget to see the structure
389
+ sample = self.db.budgets.find_one()
390
+ if sample:
391
+ print(f"Sample budget structure - createdBy type: {type(sample.get('createdBy')).__name__}, value: {sample.get('createdBy')}")
392
+ print(f"Sample budget has user_id field: {'user_id' in sample}")
393
  return {}
394
 
395
+ print(f"Found {len(budgets)} budgets for user_id: {user_id}")
396
+
397
  result: Dict[str, Dict] = {}
398
  for b in budgets:
399
+ # First, try to extract categories from headCategories array
400
+ head_categories = b.get("headCategories", [])
401
+
402
+ if head_categories and isinstance(head_categories, list):
403
+ # Process nested categories from headCategories
404
+ for head_cat in head_categories:
405
+ if not isinstance(head_cat, dict):
406
+ continue
407
+
408
+ # Get headCategory ID and amounts
409
+ head_cat_id = head_cat.get("headCategory")
410
+ try:
411
+ head_cat_max = float(head_cat.get("maxAmount", 0) or 0)
412
+ head_cat_spend = float(head_cat.get("spendAmount", 0) or 0)
413
+ except (ValueError, TypeError):
414
+ head_cat_max = 0
415
+ head_cat_spend = 0
416
+
417
+ # Process nested categories within headCategory
418
+ nested_categories = head_cat.get("categories", [])
419
+ if nested_categories and isinstance(nested_categories, list):
420
+ for nested_cat in nested_categories:
421
+ if not isinstance(nested_cat, dict):
422
+ continue
423
+
424
+ nested_cat_id = nested_cat.get("category")
425
+ try:
426
+ nested_cat_max = float(nested_cat.get("maxAmount", 0) or 0)
427
+ nested_cat_spend = float(nested_cat.get("spendAmount", 0) or 0)
428
+ except (ValueError, TypeError):
429
+ nested_cat_max = 0
430
+ nested_cat_spend = 0
431
+ spend_limit_type = nested_cat.get("spendLimitType", "NO_LIMIT")
432
+
433
+ # Only include categories with limits (must have maxAmount > 0)
434
+ if nested_cat_max > 0:
435
+ # Look up actual category name
436
+ nested_category_name = self._get_category_name(nested_cat_id)
437
+ nested_base_amount = nested_cat_spend if nested_cat_spend > 0 else nested_cat_max
438
+
439
+ if nested_category_name not in result:
440
+ result[nested_category_name] = {
441
+ "average_monthly": nested_base_amount,
442
+ "total": nested_base_amount,
443
+ "count": 1,
444
+ "months_analyzed": 1,
445
+ "std_dev": 0.0,
446
+ "monthly_values": [nested_base_amount],
447
+ }
448
+ else:
449
+ result[nested_category_name]["total"] += nested_base_amount
450
+ result[nested_category_name]["count"] += 1
451
+ result[nested_category_name]["months_analyzed"] = result[nested_category_name]["count"]
452
+ result[nested_category_name]["average_monthly"] = (
453
+ result[nested_category_name]["total"] / result[nested_category_name]["count"]
454
+ )
455
+ result[nested_category_name]["monthly_values"].append(nested_base_amount)
456
+
457
+ # Also include headCategory if it has amounts
458
+ if head_cat_max > 0 or head_cat_spend > 0:
459
+ head_category_name = self._get_category_name(head_cat_id)
460
+ head_base_amount = head_cat_spend if head_cat_spend > 0 else head_cat_max
461
+
462
+ if head_category_name not in result:
463
+ result[head_category_name] = {
464
+ "average_monthly": head_base_amount,
465
+ "total": head_base_amount,
466
+ "count": 1,
467
+ "months_analyzed": 1,
468
+ "std_dev": 0.0,
469
+ "monthly_values": [head_base_amount],
470
+ }
471
+ else:
472
+ result[head_category_name]["total"] += head_base_amount
473
+ result[head_category_name]["count"] += 1
474
+ result[head_category_name]["months_analyzed"] = result[head_category_name]["count"]
475
+ result[head_category_name]["average_monthly"] = (
476
+ result[head_category_name]["total"] / result[head_category_name]["count"]
477
+ )
478
+ result[head_category_name]["monthly_values"].append(head_base_amount)
479
+
480
+ # Also include the main budget as a category (if it has amounts)
481
+ budget_name = b.get("name", "Uncategorized")
482
+ if not budget_name or budget_name == "Uncategorized":
483
+ budget_name = b.get("category") or b.get("title") or "Uncategorized"
484
 
485
  # Derive a base amount from WalletSync fields
486
+ try:
487
+ max_amount = float(b.get("maxAmount", 0) or b.get("max_amount", 0) or b.get("amount", 0) or 0)
488
+ spend_amount = float(b.get("spendAmount", 0) or b.get("spend_amount", 0) or b.get("spent", 0) or 0)
489
+ budget_amount = float(b.get("budget", 0) or b.get("budgetAmount", 0) or 0)
490
+ except (ValueError, TypeError):
491
+ max_amount = 0
492
+ spend_amount = 0
493
+ budget_amount = 0
494
 
495
+ # Priority: spendAmount > maxAmount > budgetAmount > budget
496
+ if spend_amount > 0:
497
+ base_amount = spend_amount
498
+ elif max_amount > 0:
499
+ base_amount = max_amount
500
+ elif budget_amount > 0:
501
+ base_amount = budget_amount
 
 
502
  else:
503
+ base_amount = 0
 
 
 
 
 
 
 
504
 
505
+ # Only add main budget if it has an amount and we haven't processed categories
506
+ if base_amount > 0:
507
+ if budget_name not in result:
508
+ result[budget_name] = {
509
+ "average_monthly": base_amount,
510
+ "total": base_amount,
511
+ "count": 1,
512
+ "months_analyzed": 1,
513
+ "std_dev": 0.0,
514
+ "monthly_values": [base_amount],
515
+ }
516
+ else:
517
+ result[budget_name]["total"] += base_amount
518
+ result[budget_name]["count"] += 1
519
+ result[budget_name]["months_analyzed"] = result[budget_name]["count"]
520
+ result[budget_name]["average_monthly"] = (
521
+ result[budget_name]["total"] / result[budget_name]["count"]
522
+ )
523
+ result[budget_name]["monthly_values"].append(base_amount)
524
+
525
+ print(f"Processed {len(result)} budget categories for recommendations")
526
  return result
527
 
528
  def _get_ai_recommendation(self, category: str, data: Dict, avg_expense: float):
 
530
  if not OPENAI_API_KEY:
531
  return None
532
 
533
+ # Handle empty monthly_values
534
+ if not data.get("monthly_values") or len(data["monthly_values"]) == 0:
535
+ history = f"{avg_expense:.0f}"
536
+ else:
537
+ history = ", ".join(f"{value:.0f}" for value in data["monthly_values"])
538
+
539
  summary = (
540
  f"Category: {category}\n"
541
  f"Monthly totals: [{history}]\n"
 
556
 
557
  try:
558
  response = requests.post(
559
+ "https://api.openai.com/v1/chat/completions",
560
  headers={
561
  "Authorization": f"Bearer {OPENAI_API_KEY}",
562
  "Content-Type": "application/json",
563
  },
564
  json={
565
+ "model": "gpt-4o-mini",
566
+ "messages": [
567
+ {"role": "user", "content": prompt}
568
+ ],
569
  "temperature": 0.1,
570
  "response_format": {"type": "json_object"},
571
  },
572
  timeout=30,
573
  )
574
  response.raise_for_status()
575
+ response_data = response.json()
576
+ content = response_data["choices"][0]["message"]["content"]
577
  return json.loads(content)
578
  except Exception as exc:
579
  print(f"OpenAI recommendation error for {category}: {exc}")