From 6ae36b51a03472b3c43fba4ca7dd309c15fb4793 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Sat, 31 Jan 2026 22:47:43 -0500 Subject: [PATCH] ynab update --- .env.example | 9 + .envrc | 1 + aerich_config.py | 4 + app.py | 4 + blueprints/conversation/__init__.py | 10 +- blueprints/conversation/agents.py | 211 ++++++++++- blueprints/rag/fetchers.py | 4 + blueprints/rag/logic.py | 4 + config/oidc_config.py | 4 + docs/ynab_integration/specification.md | 0 pyproject.toml | 1 + raggr-frontend/src/api/conversationService.ts | 2 + raggr-frontend/src/components/ChatScreen.tsx | 39 +- utils/ynab_service.py | 342 ++++++++++++++++++ uv.lock | 18 + 15 files changed, 645 insertions(+), 8 deletions(-) create mode 100644 .envrc create mode 100644 docs/ynab_integration/specification.md create mode 100644 utils/ynab_service.py diff --git a/.env.example b/.env.example index 0e0693d..017b0e2 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,9 @@ CHROMADB_PATH=./data/chromadb # OpenAI Configuration OPENAI_API_KEY=your-openai-api-key +# Tavily Configuration (for web search) +TAVILY_API_KEY=your-tavily-api-key + # Immich Configuration IMMICH_URL=http://192.168.1.5:2283 IMMICH_API_KEY=your-immich-api-key @@ -45,3 +48,9 @@ OIDC_USE_DISCOVERY=true # OIDC_TOKEN_ENDPOINT=https://auth.example.com/api/oidc/token # OIDC_USERINFO_ENDPOINT=https://auth.example.com/api/oidc/userinfo # OIDC_JWKS_URI=https://auth.example.com/api/oidc/jwks + +# YNAB Configuration +# Get your Personal Access Token from https://app.ynab.com/settings/developer +YNAB_ACCESS_TOKEN=your-ynab-personal-access-token +# Optional: Specify a budget ID, or leave empty to use the default/first budget +YNAB_BUDGET_ID= diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..2c57cda --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +dotenv_if_exists diff --git a/aerich_config.py b/aerich_config.py index 1f80157..bfacaa9 100644 --- a/aerich_config.py +++ b/aerich_config.py @@ -1,4 +1,8 @@ import os +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() # Database configuration with environment variable support # Use DATABASE_PATH for relative paths or DATABASE_URL for full connection strings diff --git a/app.py b/app.py index 401dd47..a8369f8 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ import os +from dotenv import load_dotenv from quart import Quart, jsonify, render_template, request, send_from_directory from quart_jwt_extended import JWTManager, get_jwt_identity, jwt_refresh_token_required from tortoise.contrib.quart import register_tortoise @@ -11,6 +12,9 @@ import blueprints.users import blueprints.users.models from main import consult_simba_oracle +# Load environment variables +load_dotenv() + app = Quart( __name__, static_folder="raggr-frontend/dist/static", diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py index 99becaa..a29ab7c 100644 --- a/blueprints/conversation/__init__.py +++ b/blueprints/conversation/__init__.py @@ -84,7 +84,15 @@ Upcoming Appointments: - Routine Examination: Due 6/1/2026 - FVRCP-3yr Vaccine: Due 10/2/2026 -IMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions.""", +IMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions. + +BUDGET & FINANCE (YNAB Integration): +You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools: +- Use ynab_budget_summary for overall budget health and status questions +- Use ynab_search_transactions to find specific purchases or spending at particular stores +- Use ynab_category_spending to analyze spending by category for a month +- Use ynab_insights to provide spending trends, patterns, and recommendations +Always use these tools when asked about budgets, spending, transactions, or financial health.""", } ] diff --git a/blueprints/conversation/agents.py b/blueprints/conversation/agents.py index 13f914c..56514dc 100644 --- a/blueprints/conversation/agents.py +++ b/blueprints/conversation/agents.py @@ -1,6 +1,7 @@ import os from typing import cast +from dotenv import load_dotenv from langchain.agents import create_agent from langchain.chat_models import BaseChatModel from langchain.tools import tool @@ -8,6 +9,10 @@ from langchain_openai import ChatOpenAI from tavily import AsyncTavilyClient from blueprints.rag.logic import query_vector_store +from utils.ynab_service import YNABService + +# Load environment variables +load_dotenv() # Configure LLM with llama-server or OpenAI fallback llama_url = os.getenv("LLAMA_SERVER_URL") @@ -25,7 +30,15 @@ model_with_fallback = cast( BaseChatModel, llama_chat.with_fallbacks([openai_fallback]) if llama_chat else openai_fallback, ) -client = AsyncTavilyClient(os.getenv("TAVILY_KEY"), "") +client = AsyncTavilyClient(api_key=os.getenv("TAVILY_API_KEY", "")) + +# Initialize YNAB service (will only work if YNAB_ACCESS_TOKEN is set) +try: + ynab_service = YNABService() + ynab_enabled = True +except (ValueError, Exception) as e: + print(f"YNAB service not initialized: {e}") + ynab_enabled = False @tool @@ -85,4 +98,198 @@ async def simba_search(query: str): return serialized, docs -main_agent = create_agent(model=model_with_fallback, tools=[simba_search, web_search]) +@tool +def ynab_budget_summary() -> str: + """Get overall budget summary and health status from YNAB. + + Use this tool when the user asks about: + - Overall budget health or status + - How much money is to be budgeted + - Total budget amounts or spending + - General budget overview questions + + Returns: + Summary of budget health, to-be-budgeted amount, total budgeted, + total activity, and available amounts. + """ + if not ynab_enabled: + return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable." + + try: + summary = ynab_service.get_budget_summary() + return summary["summary"] + except Exception as e: + return f"Error fetching budget summary: {str(e)}" + + +@tool +def ynab_search_transactions( + start_date: str = "", + end_date: str = "", + category_name: str = "", + payee_name: str = "", +) -> str: + """Search YNAB transactions by date range, category, or payee. + + Use this tool when the user asks about: + - Specific transactions or purchases + - Spending at a particular store or payee + - Transactions in a specific category + - What was spent during a time period + + Args: + start_date: Start date in YYYY-MM-DD format (optional, defaults to 30 days ago) + end_date: End date in YYYY-MM-DD format (optional, defaults to today) + category_name: Filter by category name (optional, partial match) + payee_name: Filter by payee/store name (optional, partial match) + + Returns: + List of matching transactions with dates, amounts, categories, and payees. + """ + if not ynab_enabled: + return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable." + + try: + result = ynab_service.get_transactions( + start_date=start_date or None, + end_date=end_date or None, + category_name=category_name or None, + payee_name=payee_name or None, + ) + + if result["count"] == 0: + return "No transactions found matching the specified criteria." + + # Format transactions for readability + txn_list = [] + for txn in result["transactions"][:10]: # Limit to 10 for readability + txn_list.append( + f"- {txn['date']}: {txn['payee']} - ${abs(txn['amount']):.2f} ({txn['category'] or 'Uncategorized'})" + ) + + return ( + f"Found {result['count']} transactions from {result['start_date']} to {result['end_date']}. " + f"Total: ${abs(result['total_amount']):.2f}\n\n" + + "\n".join(txn_list) + + ( + f"\n\n(Showing first 10 of {result['count']} transactions)" + if result["count"] > 10 + else "" + ) + ) + except Exception as e: + return f"Error searching transactions: {str(e)}" + + +@tool +def ynab_category_spending(month: str = "") -> str: + """Get spending breakdown by category for a specific month. + + Use this tool when the user asks about: + - Spending by category + - What categories were overspent + - Monthly spending breakdown + - Budget vs actual spending for a month + + Args: + month: Month in YYYY-MM format (optional, defaults to current month) + + Returns: + Spending breakdown by category with budgeted, spent, and available amounts. + """ + if not ynab_enabled: + return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable." + + try: + result = ynab_service.get_category_spending(month=month or None) + + summary = ( + f"Budget spending for {result['month']}:\n" + f"Total budgeted: ${result['total_budgeted']:.2f}\n" + f"Total spent: ${result['total_spent']:.2f}\n" + f"Total available: ${result['total_available']:.2f}\n" + ) + + if result["overspent_categories"]: + summary += ( + f"\nOverspent categories ({len(result['overspent_categories'])}):\n" + ) + for cat in result["overspent_categories"][:5]: + summary += f"- {cat['name']}: Budgeted ${cat['budgeted']:.2f}, Spent ${cat['spent']:.2f}, Over by ${cat['overspent_by']:.2f}\n" + + # Add top spending categories + summary += "\nTop spending categories:\n" + for cat in result["categories"][:10]: + if cat["activity"] < 0: # Only show spending (negative activity) + summary += f"- {cat['category']}: ${abs(cat['activity']):.2f} (budgeted: ${cat['budgeted']:.2f}, available: ${cat['available']:.2f})\n" + + return summary + except Exception as e: + return f"Error fetching category spending: {str(e)}" + + +@tool +def ynab_insights(months_back: int = 3) -> str: + """Generate insights about spending patterns and budget health over time. + + Use this tool when the user asks about: + - Spending trends or patterns + - Budget recommendations + - Which categories are frequently overspent + - How current spending compares to past months + - Overall budget health analysis + + Args: + months_back: Number of months to analyze (default 3, max 6) + + Returns: + Insights about spending trends, frequently overspent categories, + and personalized recommendations. + """ + if not ynab_enabled: + return "YNAB integration is not configured. Please set YNAB_ACCESS_TOKEN environment variable." + + try: + # Limit to reasonable range + months_back = min(max(1, months_back), 6) + result = ynab_service.get_spending_insights(months_back=months_back) + + if "error" in result: + return result["error"] + + summary = ( + f"Spending insights for the last {months_back} months:\n\n" + f"Average monthly spending: ${result['average_monthly_spending']:.2f}\n" + f"Current month spending: ${result['current_month_spending']:.2f}\n" + f"Spending trend: {result['spending_trend']}\n" + ) + + if result["frequently_overspent_categories"]: + summary += "\nFrequently overspent categories:\n" + for cat in result["frequently_overspent_categories"][:5]: + summary += f"- {cat['category']}: overspent in {cat['months_overspent']} of {months_back} months\n" + + if result["recommendations"]: + summary += "\nRecommendations:\n" + for rec in result["recommendations"]: + summary += f"- {rec}\n" + + return summary + except Exception as e: + return f"Error generating insights: {str(e)}" + + +# Create tools list based on what's available +tools = [simba_search, web_search] +if ynab_enabled: + tools.extend( + [ + ynab_budget_summary, + ynab_search_transactions, + ynab_category_spending, + ynab_insights, + ] + ) + +# Llama 3.1 supports native function calling via OpenAI-compatible API +main_agent = create_agent(model=model_with_fallback, tools=tools) diff --git a/blueprints/rag/fetchers.py b/blueprints/rag/fetchers.py index a544f76..70135d4 100644 --- a/blueprints/rag/fetchers.py +++ b/blueprints/rag/fetchers.py @@ -1,8 +1,12 @@ import os import tempfile +from dotenv import load_dotenv import httpx +# Load environment variables +load_dotenv() + class PaperlessNGXService: def __init__(self): diff --git a/blueprints/rag/logic.py b/blueprints/rag/logic.py index 245f7c0..f694640 100644 --- a/blueprints/rag/logic.py +++ b/blueprints/rag/logic.py @@ -1,6 +1,7 @@ import datetime import os +from dotenv import load_dotenv from langchain_chroma import Chroma from langchain_core.documents import Document from langchain_openai import OpenAIEmbeddings @@ -8,6 +9,9 @@ from langchain_text_splitters import RecursiveCharacterTextSplitter from .fetchers import PaperlessNGXService +# Load environment variables +load_dotenv() + embeddings = OpenAIEmbeddings(model="text-embedding-3-small") vector_store = Chroma( diff --git a/config/oidc_config.py b/config/oidc_config.py index fe3b996..8056bc8 100644 --- a/config/oidc_config.py +++ b/config/oidc_config.py @@ -7,6 +7,10 @@ from typing import Dict, Any from authlib.jose import jwt from authlib.jose.errors import JoseError import httpx +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() class OIDCConfig: diff --git a/docs/ynab_integration/specification.md b/docs/ynab_integration/specification.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 357816b..3d79e3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "langchain-community>=0.4.1", "jq>=1.10.0", "tavily-python>=0.7.17", + "ynab>=1.3.0", ] [tool.aerich] diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index 1f9d429..f5bfa1a 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -35,12 +35,14 @@ class ConversationService { async sendQuery( query: string, conversation_id: string, + signal?: AbortSignal, ): Promise { const response = await userService.fetchWithRefreshToken( `${this.conversationBaseUrl}/query`, { method: "POST", body: JSON.stringify({ query, conversation_id }), + signal, }, ); diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index de48f33..8e358ec 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -43,12 +43,26 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { const [isLoading, setIsLoading] = useState(false); const messagesEndRef = useRef(null); + const isMountedRef = useRef(true); + const abortControllerRef = useRef(null); const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; + // Cleanup effect to handle component unmounting + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + // Abort any pending requests when component unmounts + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + const handleSelectConversation = (conversation: Conversation) => { setShowConversations(false); setSelectedConversation(conversation); @@ -156,10 +170,15 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { return; } + // Create a new AbortController for this request + const abortController = new AbortController(); + abortControllerRef.current = abortController; + try { const result = await conversationService.sendQuery( query, selectedConversation.id, + abortController.signal, ); setQuestionsAnswers( questionsAnswers.concat([{ question: query, answer: result.response }]), @@ -168,13 +187,23 @@ export const ChatScreen = ({ setAuthenticated }: ChatScreenProps) => { currMessages.concat([{ text: result.response, speaker: "simba" }]), ); } catch (error) { - console.error("Failed to send query:", error); - // If session expired, redirect to login - if (error instanceof Error && error.message.includes("Session expired")) { - setAuthenticated(false); + // Ignore abort errors (these are intentional cancellations) + if (error instanceof Error && error.name === "AbortError") { + console.log("Request was aborted"); + } else { + console.error("Failed to send query:", error); + // If session expired, redirect to login + if (error instanceof Error && error.message.includes("Session expired")) { + setAuthenticated(false); + } } } finally { - setIsLoading(false); + // Only update loading state if component is still mounted + if (isMountedRef.current) { + setIsLoading(false); + } + // Clear the abort controller reference + abortControllerRef.current = null; } }; diff --git a/utils/ynab_service.py b/utils/ynab_service.py new file mode 100644 index 0000000..769aa22 --- /dev/null +++ b/utils/ynab_service.py @@ -0,0 +1,342 @@ +"""YNAB API service for querying budget data.""" + +import os +from datetime import datetime, timedelta +from typing import Any, Optional + +from dotenv import load_dotenv +import ynab + +# Load environment variables +load_dotenv() + + +class YNABService: + """Service for interacting with YNAB API.""" + + def __init__(self): + """Initialize YNAB API client.""" + self.access_token = os.getenv("YNAB_ACCESS_TOKEN", "") + self.budget_id = os.getenv("YNAB_BUDGET_ID", "") + + if not self.access_token: + raise ValueError("YNAB_ACCESS_TOKEN environment variable is required") + + # Configure API client + configuration = ynab.Configuration(access_token=self.access_token) + self.api_client = ynab.ApiClient(configuration) + + # Initialize API endpoints + self.budgets_api = ynab.BudgetsApi(self.api_client) + self.transactions_api = ynab.TransactionsApi(self.api_client) + self.months_api = ynab.MonthsApi(self.api_client) + self.categories_api = ynab.CategoriesApi(self.api_client) + + # Get budget ID if not provided + if not self.budget_id: + budgets_response = self.budgets_api.get_budgets() + if budgets_response.data and budgets_response.data.budgets: + self.budget_id = budgets_response.data.budgets[0].id + else: + raise ValueError("No YNAB budgets found") + + def get_budget_summary(self) -> dict[str, Any]: + """Get overall budget summary and health status. + + Returns: + Dictionary containing budget summary with to-be-budgeted amount, + total budgeted, total activity, and overall budget health. + """ + budget_response = self.budgets_api.get_budget_by_id(self.budget_id) + budget_data = budget_response.data.budget + + # Calculate totals from categories + to_be_budgeted = ( + budget_data.months[0].to_be_budgeted / 1000 if budget_data.months else 0 + ) + + total_budgeted = 0 + total_activity = 0 + total_available = 0 + + for category_group in budget_data.category_groups or []: + if category_group.deleted or category_group.hidden: + continue + for category in category_group.categories or []: + if category.deleted or category.hidden: + continue + total_budgeted += category.budgeted / 1000 + total_activity += category.activity / 1000 + total_available += category.balance / 1000 + + return { + "budget_name": budget_data.name, + "to_be_budgeted": round(to_be_budgeted, 2), + "total_budgeted": round(total_budgeted, 2), + "total_activity": round(total_activity, 2), + "total_available": round(total_available, 2), + "currency_format": budget_data.currency_format.iso_code + if budget_data.currency_format + else "USD", + "summary": f"Budget '{budget_data.name}' has ${abs(to_be_budgeted):.2f} {'to be budgeted' if to_be_budgeted > 0 else 'overbudgeted'}. " + f"Total budgeted: ${total_budgeted:.2f}, Total spent: ${abs(total_activity):.2f}, " + f"Total available: ${total_available:.2f}.", + } + + def get_transactions( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + category_name: Optional[str] = None, + payee_name: Optional[str] = None, + limit: int = 50, + ) -> dict[str, Any]: + """Get transactions filtered by date range, category, or payee. + + Args: + start_date: Start date in YYYY-MM-DD format (defaults to 30 days ago) + end_date: End date in YYYY-MM-DD format (defaults to today) + category_name: Filter by category name (case-insensitive partial match) + payee_name: Filter by payee name (case-insensitive partial match) + limit: Maximum number of transactions to return (default 50) + + Returns: + Dictionary containing matching transactions and summary statistics. + """ + # Set default date range if not provided + if not start_date: + start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") + if not end_date: + end_date = datetime.now().strftime("%Y-%m-%d") + + # Get transactions + transactions_response = self.transactions_api.get_transactions( + self.budget_id, since_date=start_date + ) + + transactions = transactions_response.data.transactions or [] + + # Filter by date range, category, and payee + filtered_transactions = [] + total_amount = 0 + + for txn in transactions: + # Skip if deleted or before start date or after end date + if txn.deleted: + continue + txn_date = str(txn.date) + if txn_date < start_date or txn_date > end_date: + continue + + # Filter by category if specified + if category_name and txn.category_name: + if category_name.lower() not in txn.category_name.lower(): + continue + + # Filter by payee if specified + if payee_name and txn.payee_name: + if payee_name.lower() not in txn.payee_name.lower(): + continue + + amount = txn.amount / 1000 # Convert milliunits to dollars + filtered_transactions.append( + { + "date": txn_date, + "payee": txn.payee_name, + "category": txn.category_name, + "memo": txn.memo, + "amount": round(amount, 2), + "approved": txn.approved, + } + ) + total_amount += amount + + # Sort by date (most recent first) and limit + filtered_transactions.sort(key=lambda x: x["date"], reverse=True) + filtered_transactions = filtered_transactions[:limit] + + return { + "transactions": filtered_transactions, + "count": len(filtered_transactions), + "total_amount": round(total_amount, 2), + "start_date": start_date, + "end_date": end_date, + "filters": {"category": category_name, "payee": payee_name}, + } + + def get_category_spending(self, month: Optional[str] = None) -> dict[str, Any]: + """Get spending breakdown by category for a specific month. + + Args: + month: Month in YYYY-MM format (defaults to current month) + + Returns: + Dictionary containing spending by category and summary. + """ + if not month: + month = datetime.now().strftime("%Y-%m-01") + else: + # Ensure format is YYYY-MM-01 + if len(month) == 7: # YYYY-MM + month = f"{month}-01" + + # Get budget month + month_response = self.months_api.get_budget_month(self.budget_id, month) + + month_data = month_response.data.month + + categories_spending = [] + total_budgeted = 0 + total_activity = 0 + total_available = 0 + overspent_categories = [] + + for category in month_data.categories or []: + if category.deleted or category.hidden: + continue + + budgeted = category.budgeted / 1000 + activity = category.activity / 1000 + available = category.balance / 1000 + + total_budgeted += budgeted + total_activity += activity + total_available += available + + # Track overspent categories + if available < 0: + overspent_categories.append( + { + "name": category.name, + "budgeted": round(budgeted, 2), + "spent": round(abs(activity), 2), + "overspent_by": round(abs(available), 2), + } + ) + + # Only include categories with activity + if activity != 0: + categories_spending.append( + { + "category": category.name, + "budgeted": round(budgeted, 2), + "activity": round(activity, 2), + "available": round(available, 2), + "goal_type": category.goal_type + if hasattr(category, "goal_type") + else None, + } + ) + + # Sort by absolute activity (highest spending first) + categories_spending.sort(key=lambda x: abs(x["activity"]), reverse=True) + + return { + "month": month[:7], # Return YYYY-MM format + "categories": categories_spending, + "total_budgeted": round(total_budgeted, 2), + "total_spent": round(abs(total_activity), 2), + "total_available": round(total_available, 2), + "overspent_categories": overspent_categories, + "to_be_budgeted": round(month_data.to_be_budgeted / 1000, 2) + if month_data.to_be_budgeted + else 0, + } + + def get_spending_insights(self, months_back: int = 3) -> dict[str, Any]: + """Generate insights about spending patterns and budget health. + + Args: + months_back: Number of months to analyze (default 3) + + Returns: + Dictionary containing spending insights, trends, and recommendations. + """ + current_month = datetime.now() + monthly_data = [] + + # Collect data for the last N months + for i in range(months_back): + month_date = current_month - timedelta(days=30 * i) + month_str = month_date.strftime("%Y-%m-01") + + try: + month_spending = self.get_category_spending(month_str) + monthly_data.append( + { + "month": month_str[:7], + "total_spent": month_spending["total_spent"], + "total_budgeted": month_spending["total_budgeted"], + "overspent_categories": month_spending["overspent_categories"], + } + ) + except Exception: + # Skip months that don't have data yet + continue + + if not monthly_data: + return {"error": "No spending data available for analysis"} + + # Calculate average spending + avg_spending = sum(m["total_spent"] for m in monthly_data) / len(monthly_data) + current_spending = monthly_data[0]["total_spent"] if monthly_data else 0 + + # Identify spending trend + if len(monthly_data) >= 2: + recent_avg = sum(m["total_spent"] for m in monthly_data[:2]) / 2 + older_avg = sum(m["total_spent"] for m in monthly_data[1:]) / ( + len(monthly_data) - 1 + ) + trend = ( + "increasing" + if recent_avg > older_avg * 1.1 + else "decreasing" + if recent_avg < older_avg * 0.9 + else "stable" + ) + else: + trend = "insufficient data" + + # Find frequently overspent categories + overspent_frequency = {} + for month in monthly_data: + for cat in month["overspent_categories"]: + cat_name = cat["name"] + if cat_name not in overspent_frequency: + overspent_frequency[cat_name] = 0 + overspent_frequency[cat_name] += 1 + + frequently_overspent = [ + {"category": cat, "months_overspent": count} + for cat, count in overspent_frequency.items() + if count > 1 + ] + frequently_overspent.sort(key=lambda x: x["months_overspent"], reverse=True) + + # Generate recommendations + recommendations = [] + if current_spending > avg_spending * 1.2: + recommendations.append( + f"Current month spending (${current_spending:.2f}) is significantly higher than your {months_back}-month average (${avg_spending:.2f})" + ) + + if frequently_overspent: + top_overspent = frequently_overspent[0] + recommendations.append( + f"'{top_overspent['category']}' has been overspent in {top_overspent['months_overspent']} of the last {months_back} months" + ) + + if trend == "increasing": + recommendations.append( + "Your spending trend is increasing. Consider reviewing your budget allocations." + ) + + return { + "analysis_period": f"Last {months_back} months", + "average_monthly_spending": round(avg_spending, 2), + "current_month_spending": round(current_spending, 2), + "spending_trend": trend, + "frequently_overspent_categories": frequently_overspent, + "recommendations": recommendations, + "monthly_breakdown": monthly_data, + } diff --git a/uv.lock b/uv.lock index 5eb29e4..4eb899f 100644 --- a/uv.lock +++ b/uv.lock @@ -2551,6 +2551,7 @@ dependencies = [ { name = "tomlkit" }, { name = "tortoise-orm" }, { name = "tortoise-orm-stubs" }, + { name = "ynab" }, ] [package.metadata] @@ -2586,6 +2587,7 @@ requires-dist = [ { name = "tomlkit", specifier = ">=0.13.3" }, { name = "tortoise-orm", specifier = ">=0.25.1" }, { name = "tortoise-orm-stubs", specifier = ">=1.0.2" }, + { name = "ynab", specifier = ">=1.3.0" }, ] [[package]] @@ -3385,6 +3387,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, ] +[[package]] +name = "ynab" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/3e/36599ae876db3e1d32e393ab0934547df75bab70373c14ca5805246f99bc/ynab-1.9.0.tar.gz", hash = "sha256:fa50bdff641b3a273661e9f6e8a210f5ad98991a998dc09dec0a8122d734d1c6", size = 64898, upload-time = "2025-10-06T19:14:32.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/9c/0ccd11bcdf7522fcb2823fcd7ffbb48e3164d72caaf3f920c7b068347175/ynab-1.9.0-py3-none-any.whl", hash = "sha256:72ac0219605b4280149684ecd0fec3bd75d938772d65cdeea9b3e66a1b2f470d", size = 208674, upload-time = "2025-10-06T19:14:31.719Z" }, +] + [[package]] name = "zipp" version = "3.23.0"