Commit
·
ecfd501
1
Parent(s):
6a4c6a3
Add new endpoint /recommendations/by-name to accept Head Category names instead of category_id for easier frontend integration
Browse files- app/main.py +106 -1
- app/models.py +15 -0
- app/smart_recommendation.py +44 -0
app/main.py
CHANGED
|
@@ -6,7 +6,7 @@ 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, RecommendationRequest, RecommendationResponse
|
| 10 |
from app.smart_recommendation import SmartBudgetRecommender
|
| 11 |
|
| 12 |
app = FastAPI(title="Smart Budget Recommendation API", version="1.0.0")
|
|
@@ -262,6 +262,111 @@ async def check_and_get_recommendations(request: RecommendationRequest, month: O
|
|
| 262 |
message=f"User does not have previous data for category_id: {request.category_id}. Please provide a budget_amount or create a budget/expenses for this category first."
|
| 263 |
)
|
| 264 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
@app.get("/recommendations/{user_id}", response_model=List[BudgetRecommendation])
|
| 266 |
async def get_budget_recommendations(user_id: str, month: Optional[int] = None, year: Optional[int] = None):
|
| 267 |
"""
|
|
|
|
| 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, RecommendationRequest, RecommendationResponse, RecommendationByNameRequest, RecommendationByNameRequest
|
| 10 |
from app.smart_recommendation import SmartBudgetRecommender
|
| 11 |
|
| 12 |
app = FastAPI(title="Smart Budget Recommendation API", version="1.0.0")
|
|
|
|
| 262 |
message=f"User does not have previous data for category_id: {request.category_id}. Please provide a budget_amount or create a budget/expenses for this category first."
|
| 263 |
)
|
| 264 |
|
| 265 |
+
@app.post("/recommendations/by-name", response_model=RecommendationResponse)
|
| 266 |
+
async def get_recommendations_by_category_name(request: RecommendationByNameRequest, month: Optional[int] = None, year: Optional[int] = None):
|
| 267 |
+
"""
|
| 268 |
+
Get budget recommendations by Head Category name (instead of category_id).
|
| 269 |
+
This is useful for frontend integration where you only have the category name.
|
| 270 |
+
|
| 271 |
+
Request body:
|
| 272 |
+
{
|
| 273 |
+
"user_id": "6741abd38d30ab5b7176397f",
|
| 274 |
+
"category_name": "Food & Drinks",
|
| 275 |
+
"budget_amount": 350.0 // optional
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
Supported Head Category names:
|
| 279 |
+
- Food & Drinks
|
| 280 |
+
- Shopping
|
| 281 |
+
- Housing
|
| 282 |
+
- Financial Expenses
|
| 283 |
+
- Communication, PC
|
| 284 |
+
- Vehicle
|
| 285 |
+
- Transportation
|
| 286 |
+
- Life & Entertainment
|
| 287 |
+
- Investments
|
| 288 |
+
- Income
|
| 289 |
+
- Loan Payment
|
| 290 |
+
- Marketing
|
| 291 |
+
|
| 292 |
+
Response:
|
| 293 |
+
- If user has previous data: returns recommendations based on historical data
|
| 294 |
+
- If user doesn't have previous data but provided budget_amount: returns recommendations based on budget_amount
|
| 295 |
+
- If user doesn't have previous data and no budget_amount: returns message asking for budget_amount
|
| 296 |
+
"""
|
| 297 |
+
if not month or not year:
|
| 298 |
+
# Default to next month
|
| 299 |
+
next_month = datetime.now().replace(day=1) + timedelta(days=32)
|
| 300 |
+
month = next_month.month
|
| 301 |
+
year = next_month.year
|
| 302 |
+
|
| 303 |
+
# Find category_id by name
|
| 304 |
+
category_id = recommender._get_category_id_by_name(request.category_name)
|
| 305 |
+
|
| 306 |
+
if not category_id:
|
| 307 |
+
return RecommendationResponse(
|
| 308 |
+
has_previous_data=False,
|
| 309 |
+
recommendations=None,
|
| 310 |
+
message=f"Category '{request.category_name}' not found in database. Please check the category name or create the category first."
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
# Now use the existing check endpoint logic
|
| 314 |
+
has_data = recommender.check_user_has_category_data(request.user_id, category_id)
|
| 315 |
+
|
| 316 |
+
if has_data:
|
| 317 |
+
# User has previous data - use it for recommendations
|
| 318 |
+
print(f"✅ User {request.user_id} has previous data for category '{request.category_name}' (id: {category_id}) - using historical data")
|
| 319 |
+
recommendations = recommender.get_recommendation_for_category(
|
| 320 |
+
request.user_id,
|
| 321 |
+
category_id,
|
| 322 |
+
month,
|
| 323 |
+
year,
|
| 324 |
+
budget_amount=None # Don't use budget_amount if user has historical data
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
if recommendations:
|
| 328 |
+
return RecommendationResponse(
|
| 329 |
+
has_previous_data=True,
|
| 330 |
+
recommendations=recommendations,
|
| 331 |
+
message=None
|
| 332 |
+
)
|
| 333 |
+
else:
|
| 334 |
+
return RecommendationResponse(
|
| 335 |
+
has_previous_data=True,
|
| 336 |
+
recommendations=[],
|
| 337 |
+
message="User has previous data but no recommendations could be generated for this category."
|
| 338 |
+
)
|
| 339 |
+
elif request.budget_amount and request.budget_amount > 0:
|
| 340 |
+
# User doesn't have previous data, but provided budget_amount
|
| 341 |
+
print(f"ℹ️ User {request.user_id} does not have previous data for category '{request.category_name}' (id: {category_id}) - using provided budget_amount")
|
| 342 |
+
recommendations = recommender.get_recommendation_for_category(
|
| 343 |
+
request.user_id,
|
| 344 |
+
category_id,
|
| 345 |
+
month,
|
| 346 |
+
year,
|
| 347 |
+
request.budget_amount
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
if recommendations:
|
| 351 |
+
return RecommendationResponse(
|
| 352 |
+
has_previous_data=False,
|
| 353 |
+
recommendations=recommendations,
|
| 354 |
+
message="Recommendation generated based on provided budget amount. User does not have previous spending data for this category."
|
| 355 |
+
)
|
| 356 |
+
else:
|
| 357 |
+
return RecommendationResponse(
|
| 358 |
+
has_previous_data=False,
|
| 359 |
+
recommendations=None,
|
| 360 |
+
message="Could not generate recommendations even with provided budget amount."
|
| 361 |
+
)
|
| 362 |
+
else:
|
| 363 |
+
# User doesn't have previous data and no budget_amount provided
|
| 364 |
+
return RecommendationResponse(
|
| 365 |
+
has_previous_data=False,
|
| 366 |
+
recommendations=None,
|
| 367 |
+
message=f"User does not have previous data for category '{request.category_name}'. Please provide a budget_amount to get recommendations."
|
| 368 |
+
)
|
| 369 |
+
|
| 370 |
@app.get("/recommendations/{user_id}", response_model=List[BudgetRecommendation])
|
| 371 |
async def get_budget_recommendations(user_id: str, month: Optional[int] = None, year: Optional[int] = None):
|
| 372 |
"""
|
app/models.py
CHANGED
|
@@ -63,3 +63,18 @@ class RecommendationResponse(BaseModel):
|
|
| 63 |
message: Optional[str] = None
|
| 64 |
recommendations: Optional[List[BudgetRecommendation]] = None
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
message: Optional[str] = None
|
| 64 |
recommendations: Optional[List[BudgetRecommendation]] = None
|
| 65 |
|
| 66 |
+
class RecommendationByNameRequest(BaseModel):
|
| 67 |
+
user_id: str = Field(..., description="User identifier")
|
| 68 |
+
category_name: str = Field(..., description="Head Category name (e.g., 'Food & Drinks', 'Shopping', 'Housing')")
|
| 69 |
+
budget_amount: Optional[float] = Field(None, gt=0, description="Current budget amount for this category (optional)")
|
| 70 |
+
|
| 71 |
+
@validator('budget_amount')
|
| 72 |
+
def validate_budget_amount(cls, v):
|
| 73 |
+
if v is not None:
|
| 74 |
+
if v <= 0:
|
| 75 |
+
raise ValueError('budget_amount must be greater than 0')
|
| 76 |
+
if v > 1e12:
|
| 77 |
+
print(f"⚠️ Auto-capping corrupted budget_amount: {v:,.2e} -> 1,000,000,000,000")
|
| 78 |
+
return 1e12
|
| 79 |
+
return v
|
| 80 |
+
|
app/smart_recommendation.py
CHANGED
|
@@ -778,6 +778,50 @@ class SmartBudgetRecommender:
|
|
| 778 |
result.sort(key=lambda x: x.average_monthly_expense, reverse=True)
|
| 779 |
return result
|
| 780 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 781 |
def _get_category_name(self, category_id) -> str:
|
| 782 |
"""
|
| 783 |
Look up category name from headCategories and categories collections.
|
|
|
|
| 778 |
result.sort(key=lambda x: x.average_monthly_expense, reverse=True)
|
| 779 |
return result
|
| 780 |
|
| 781 |
+
def _get_category_id_by_name(self, category_name: str) -> Optional[str]:
|
| 782 |
+
"""
|
| 783 |
+
Find category_id by category name from headCategories and categories collections.
|
| 784 |
+
Returns the first matching category_id found.
|
| 785 |
+
"""
|
| 786 |
+
if not category_name:
|
| 787 |
+
return None
|
| 788 |
+
|
| 789 |
+
try:
|
| 790 |
+
# Search in headCategories collection by name
|
| 791 |
+
head_category = self.db.headCategories.find_one({
|
| 792 |
+
"$or": [
|
| 793 |
+
{"name": {"$regex": category_name, "$options": "i"}},
|
| 794 |
+
{"headCategoryName": {"$regex": category_name, "$options": "i"}},
|
| 795 |
+
{"categoryName": {"$regex": category_name, "$options": "i"}}
|
| 796 |
+
]
|
| 797 |
+
})
|
| 798 |
+
|
| 799 |
+
if head_category:
|
| 800 |
+
# Return _id as string
|
| 801 |
+
category_id = str(head_category.get("_id"))
|
| 802 |
+
print(f"✅ Found category_id in headCategories: '{category_name}' -> {category_id}")
|
| 803 |
+
return category_id
|
| 804 |
+
|
| 805 |
+
# Search in categories collection by name
|
| 806 |
+
category = self.db.categories.find_one({
|
| 807 |
+
"$or": [
|
| 808 |
+
{"name": {"$regex": category_name, "$options": "i"}},
|
| 809 |
+
{"categoryName": {"$regex": category_name, "$options": "i"}}
|
| 810 |
+
]
|
| 811 |
+
})
|
| 812 |
+
|
| 813 |
+
if category:
|
| 814 |
+
category_id = str(category.get("_id"))
|
| 815 |
+
print(f"✅ Found category_id in categories: '{category_name}' -> {category_id}")
|
| 816 |
+
return category_id
|
| 817 |
+
|
| 818 |
+
print(f"⚠️ Category name not found: '{category_name}'")
|
| 819 |
+
return None
|
| 820 |
+
|
| 821 |
+
except Exception as e:
|
| 822 |
+
print(f"Error looking up category_id by name '{category_name}': {e}")
|
| 823 |
+
return None
|
| 824 |
+
|
| 825 |
def _get_category_name(self, category_id) -> str:
|
| 826 |
"""
|
| 827 |
Look up category name from headCategories and categories collections.
|