diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 0bb4e42f..df3c79b7 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -115,7 +115,7 @@ .prose--ai-chat { @apply break-words max-w-[300px]; - p { + p, li { @apply text-sm text-primary; } diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb index e18877dd..61cffab9 100644 --- a/app/controllers/chats_controller.rb +++ b/app/controllers/chats_controller.rb @@ -43,6 +43,11 @@ class ChatsController < ApplicationController redirect_to chats_path, notice: "Chat was successfully deleted" end + def retry + @chat.retry_last_message! + redirect_to chat_path(@chat) + end + private def set_chat @chat = Current.user.chats.find(params[:id]) diff --git a/app/models/account/transaction.rb b/app/models/account/transaction.rb index e31a5607..94552a28 100644 --- a/app/models/account/transaction.rb +++ b/app/models/account/transaction.rb @@ -13,5 +13,5 @@ class Account::Transaction < ApplicationRecord def search(params) Account::TransactionSearch.new(params).build_query(all) end - end + end end diff --git a/app/models/assistant/function.rb b/app/models/assistant/function.rb new file mode 100644 index 00000000..50c4f581 --- /dev/null +++ b/app/models/assistant/function.rb @@ -0,0 +1,75 @@ +class Assistant::Function + Error = Class.new(StandardError) + Response = Data.define(:success?, :data, :error) + + class << self + def name + raise NotImplementedError, "Subclasses must implement the name class method" + end + + def description + raise NotImplementedError, "Subclasses must implement the description class method" + end + end + + def initialize(user) + @user = user + end + + def call(params = {}) + raise NotImplementedError, "Subclasses must implement the call method" + end + + def name + self.class.name + end + + def description + self.class.description + end + + def params_schema + build_schema + end + + # (preferred) when in strict mode, the schema needs to include all properties in required array + def strict_mode? + true + end + + private + attr_reader :user + + def build_schema(properties: {}, required: []) + { + type: "object", + properties: properties, + required: required, + additionalProperties: false + } + end + + def family_account_names + @family_account_names ||= family.accounts.active.pluck(:name) + end + + def family_category_names + @family_category_names ||= begin + names = family.categories.pluck(:name) + names << "Uncategorized" + names + end + end + + def family_merchant_names + @family_merchant_names ||= family.merchants.pluck(:name) + end + + def family_tag_names + @family_tag_names ||= family.tags.pluck(:name) + end + + def family + user.family + end +end diff --git a/app/models/assistant/functions/get_account_balances.rb b/app/models/assistant/function/get_accounts.rb similarity index 67% rename from app/models/assistant/functions/get_account_balances.rb rename to app/models/assistant/function/get_accounts.rb index 9c8ae6ea..37f4ac60 100644 --- a/app/models/assistant/functions/get_account_balances.rb +++ b/app/models/assistant/function/get_accounts.rb @@ -1,28 +1,11 @@ -class Assistant::Functions::GetAccountBalances - include Assistant::Functions::Toolable - +class Assistant::Function::GetAccounts < Assistant::Function class << self def name - "get_account_balances" + "get_accounts" end def description - "Get balances for all accounts or by account type" - end - - def parameters - { - type: "object", - properties: { - account_type: { - type: "string", - enum: [ "asset", "liability", "all" ], - description: "Type of accounts to get balances for" - } - }, - required: [ "account_type" ], - additionalProperties: false - } + "Use this to see what accounts the user has along with their current and historical balances" end end @@ -37,6 +20,21 @@ class Assistant::Functions::GetAccountBalances } end + def params_schema + { + type: "object", + properties: { + account_type: { + type: "string", + enum: [ "asset", "liability", "all" ], + description: "Type of accounts to get balances for" + } + }, + required: [ "account_type" ], + additionalProperties: false + } + end + private def get_accounts_data(balance_sheet, account_type) diff --git a/app/models/assistant/function/get_balance_sheet.rb b/app/models/assistant/function/get_balance_sheet.rb new file mode 100644 index 00000000..dfe0fa83 --- /dev/null +++ b/app/models/assistant/function/get_balance_sheet.rb @@ -0,0 +1,122 @@ +class Assistant::Function::GetBalanceSheet < Assistant::Function + class << self + def name + "get_balance_sheet" + end + + def description + "Use this to get point-in-time snapshots of the user's aggregate financial position, including assets, liabilities, net worth, and more." + end + end + + def call(params = {}) + balance_sheet = BalanceSheet.new(family) + balance_sheet.to_ai_readable_hash + end + + private + # AI-friendly representation of balance sheet data + def to_ai_readable_hash + { + net_worth: format_currency(net_worth), + total_assets: format_currency(total_assets), + total_liabilities: format_currency(total_liabilities), + as_of_date: Date.today.to_s, + currency: currency + } + end + + # Detailed summary of the balance sheet for AI + def detailed_summary + asset_groups = account_groups("asset") + liability_groups = account_groups("liability") + + { + asset_breakdown: asset_groups.map do |group| + { + type: group.name, + total: format_currency(group.total), + percentage_of_assets: format_percentage(group.weight), + accounts: group.accounts.map do |account| + { + name: account.name, + balance: format_currency(account.balance), + percentage_of_type: format_percentage(account.weight) + } + end + } + end, + liability_breakdown: liability_groups.map do |group| + { + type: group.name, + total: format_currency(group.total), + percentage_of_liabilities: format_percentage(group.weight), + accounts: group.accounts.map do |account| + { + name: account.name, + balance: format_currency(account.balance), + percentage_of_type: format_percentage(account.weight) + } + end + } + end + } + end + + # Generate financial insights for the balance sheet + def financial_insights + prev_month_networth = previous_month_net_worth + month_change = net_worth - prev_month_networth + month_change_percentage = prev_month_networth.zero? ? 0 : (month_change / prev_month_networth.to_f * 100) + + debt_to_asset_ratio = total_assets.zero? ? 0 : (total_liabilities / total_assets.to_f) + + largest_asset_group = account_groups("asset").max_by(&:total) + largest_liability_group = account_groups("liability").max_by(&:total) + + { + summary: "Your net worth is #{format_currency(net_worth)} as of #{format_date(Date.today)}.", + monthly_change: { + amount: format_currency(month_change), + percentage: format_percentage(month_change_percentage), + trend: month_change > 0 ? "increasing" : (month_change < 0 ? "decreasing" : "stable") + }, + debt_to_asset_ratio: { + ratio: debt_to_asset_ratio.round(2), + interpretation: interpret_debt_to_asset_ratio(debt_to_asset_ratio) + }, + asset_insights: { + largest_type: largest_asset_group&.name || "None", + largest_type_amount: format_currency(largest_asset_group&.total || 0), + largest_type_percentage: format_percentage(largest_asset_group&.weight || 0) + }, + liability_insights: { + largest_type: largest_liability_group&.name || "None", + largest_type_amount: format_currency(largest_liability_group&.total || 0), + largest_type_percentage: format_percentage(largest_liability_group&.weight || 0) + } + } + end + + # Calculate the net worth from the previous month + def previous_month_net_worth + # Here we'd ideally fetch historical data + # For now, we'll estimate it using the current net worth + # In a real implementation, you might use a time series or snapshot + net_worth * 0.97 # Assume 3% growth for demo purposes + end + + # Provide an interpretation of the debt-to-asset ratio + def interpret_debt_to_asset_ratio(ratio) + case ratio + when 0...0.3 + "Your debt-to-asset ratio is low, which is generally considered financially healthy." + when 0.3...0.5 + "Your debt-to-asset ratio is moderate, which is generally manageable." + when 0.5...0.8 + "Your debt-to-asset ratio is somewhat high. You might want to focus on reducing debt." + else + "Your debt-to-asset ratio is high. Consider a debt reduction strategy." + end + end +end diff --git a/app/models/assistant/function/get_income_statement.rb b/app/models/assistant/function/get_income_statement.rb new file mode 100644 index 00000000..593b3f55 --- /dev/null +++ b/app/models/assistant/function/get_income_statement.rb @@ -0,0 +1,221 @@ +class Assistant::Function::GetIncomeStatement < Assistant::Function + + class << self + def name + "get_income_statement" + end + + def description + "Use this to get income and expense insights by category, for a specific time period" + end + end + + def call(params = {}) + income_statement = IncomeStatement.new(family) + period = get_period_from_param(params["period"]) + income_statement.to_ai_readable_hash(period: period) + end + + def params_schema + { + type: "object", + properties: { + period: { + type: "string", + enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ], + description: "The time period for the income statement data" + } + }, + required: [ "period" ], + additionalProperties: false + } + end + + private + + # AI-friendly representation of income statement data + def to_ai_readable_hash(period: Period.current_month) + expense_data = expense_totals(period: period) + income_data = income_totals(period: period) + + { + period: { + start_date: period.start_date.to_s, + end_date: period.end_date.to_s + }, + total_income: format_currency(income_data.total), + total_expenses: format_currency(expense_data.total), + net_income: format_currency(income_data.total - expense_data.total), + savings_rate: calculate_savings_rate(income_data.total, expense_data.total), + currency: family.currency + } + end + + # Detailed summary of income statement for AI + def detailed_summary(period: Period.current_month) + expense_data = expense_totals(period: period) + income_data = income_totals(period: period) + + { + period_info: { + name: period_name(period), + start_date: format_date(period.start_date), + end_date: format_date(period.end_date), + days: (period.end_date - period.start_date).to_i + 1 + }, + income: { + total: format_currency(income_data.total), + categories: income_data.category_totals + .reject { |ct| ct.category.subcategory? } + .sort_by { |ct| -ct.total } + .map do |ct| + { + name: ct.category.name, + amount: format_currency(ct.total), + percentage: format_percentage(ct.weight) + } + end + }, + expenses: { + total: format_currency(expense_data.total), + categories: expense_data.category_totals + .reject { |ct| ct.category.subcategory? } + .sort_by { |ct| -ct.total } + .map do |ct| + { + name: ct.category.name, + amount: format_currency(ct.total), + percentage: format_percentage(ct.weight) + } + end + }, + savings: { + amount: format_currency(income_data.total - expense_data.total), + rate: format_percentage(calculate_savings_rate(income_data.total, expense_data.total)) + } + } + end + + # Generate financial insights for income statement + def financial_insights(period: Period.current_month) + expense_data = expense_totals(period: period) + income_data = income_totals(period: period) + + # Compare with previous period + prev_period = get_previous_period(period) + prev_expense_data = expense_totals(period: prev_period) + prev_income_data = income_totals(period: prev_period) + + # Calculate changes + income_change = income_data.total - prev_income_data.total + expense_change = expense_data.total - prev_expense_data.total + + # Calculate percentages + income_change_pct = prev_income_data.total.zero? ? 0 : (income_change / prev_income_data.total.to_f * 100) + expense_change_pct = prev_expense_data.total.zero? ? 0 : (expense_change / prev_expense_data.total.to_f * 100) + + # Find top categories + top_expense_categories = expense_data.category_totals + .reject { |ct| ct.category.subcategory? } + .sort_by { |ct| -ct.total } + .take(3) + + top_income_categories = income_data.category_totals + .reject { |ct| ct.category.subcategory? } + .sort_by { |ct| -ct.total } + .take(3) + + current_savings_rate = calculate_savings_rate(income_data.total, expense_data.total) + previous_savings_rate = calculate_savings_rate(prev_income_data.total, prev_expense_data.total) + + { + summary: "For #{period_name(period)}, your net income is #{format_currency(income_data.total - expense_data.total)} with a savings rate of #{format_percentage(current_savings_rate)}.", + period_comparison: { + previous_period: period_name(prev_period), + income_change: { + amount: format_currency(income_change), + percentage: format_percentage(income_change_pct), + trend: income_change > 0 ? "increasing" : (income_change < 0 ? "decreasing" : "stable") + }, + expense_change: { + amount: format_currency(expense_change), + percentage: format_percentage(expense_change_pct), + trend: expense_change > 0 ? "increasing" : (expense_change < 0 ? "decreasing" : "stable") + }, + savings_rate_change: format_percentage(current_savings_rate - previous_savings_rate) + }, + expense_insights: { + top_categories: top_expense_categories.map do |ct| + { + name: ct.category.name, + amount: format_currency(ct.total), + percentage: format_percentage(ct.weight) + } + end, + daily_average: format_currency(expense_data.total / period.days), + monthly_estimate: format_currency(expense_data.total * (30.0 / period.days)) + }, + income_insights: { + top_sources: top_income_categories.map do |ct| + { + name: ct.category.name, + amount: format_currency(ct.total), + percentage: format_percentage(ct.weight) + } + end, + monthly_estimate: format_currency(income_data.total * (30.0 / period.days)) + } + } + end + + def calculate_savings_rate(total_income, total_expenses) + return 0 if total_income.zero? + savings = total_income - total_expenses + rate = (savings / total_income.to_f) * 100 + rate.round(2) + end + + # Get previous period for comparison + def get_previous_period(period) + if period.is_a?(Period) + # For custom periods, create a period of same length ending right before this period starts + length = (period.end_date - period.start_date).to_i + Period.new(start_date: period.start_date - length.days - 1.day, end_date: period.start_date - 1.day) + else + # Default to previous month + current_month = Date.today.beginning_of_month..Date.today.end_of_month + previous_month = 1.month.ago.beginning_of_month..1.month.ago.end_of_month + Period.new(start_date: previous_month.begin, end_date: previous_month.end) + end + end + + # Get a human-readable name for a period + def period_name(period) + if period == Period.current_month + "Current Month" + elsif period == Period.previous_month + "Previous Month" + elsif period == Period.year_to_date + "Year to Date" + elsif period == Period.previous_year + "Previous Year" + else + "#{format_date(period.start_date)} to #{format_date(period.end_date)}" + end + end + + def get_period_from_param(period_param) + case period_param + when "current_month" + Period.current_month + when "previous_month" + Period.previous_month + when "year_to_date" + Period.year_to_date + when "previous_year" + Period.previous_year + else + Period.current_month + end + end +end diff --git a/app/models/assistant/function/get_transactions.rb b/app/models/assistant/function/get_transactions.rb new file mode 100644 index 00000000..636b2680 --- /dev/null +++ b/app/models/assistant/function/get_transactions.rb @@ -0,0 +1,166 @@ +class Assistant::Function::GetTransactions < Assistant::Function + include Pagy::Backend + + class << self + def name + "get_transactions" + end + + def description + <<~INSTRUCTIONS + Use this to search user's transactions by using various optional filters. + + Note on pagination: + + This function can be paginated. You can expect the following properties in the response: + + - `total_pages`: The total number of pages of results + - `page`: The current page of results + - `page_size`: The number of results per page (this will always be 50) + - `total_results`: The total number of results for the given filters + + Simple example (transactions from the last 30 days): + + ``` + get_transactions({ + page: 1, + page_size: 50, + start_date: "#{30.days.ago.to_date}", + end_date: "#{Date.current}" + }) + ``` + + More complex example (various filters): + + ``` + get_transactions({ + page: 1, + page_size: 50, + search: "mcdonalds", + accounts: ["Checking", "Savings"], + start_date: "#{30.days.ago.to_date}", + end_date: "#{Date.current}", + categories: ["Restaurants"], + merchants: ["McDonald's"], + tags: ["Food"], + amount: "100", + amount_operator: "less" + }) + ``` + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: [ "order", "page", "page_size" ], + properties: { + page_size: { + const: 50, + description: "Number of transactions per page (always 50)" + }, + page: { + type: "integer", + description: "Page number" + }, + order: { + enum: [ "asc", "desc" ], + description: "Order of the transactions by date" + }, + search: { + type: "string", + description: "Search for transactions by name" + }, + amount: { + type: "string", + description: "Amount for transactions (must be used with amount_operator)" + }, + amount_operator: { + type: "string", + description: "Operator for amount (must be used with amount)", + enum: [ "equal", "less", "greater" ] + }, + start_date: { + type: "string", + description: "Start date for transactions in YYYY-MM-DD format" + }, + end_date: { + type: "string", + description: "End date for transactions in YYYY-MM-DD format" + }, + accounts: { + type: "array", + description: "Filter transactions by account name", + items: { enum: family_account_names }, + minItems: 1, + uniqueItems: true + }, + categories: { + type: "array", + description: "Filter transactions by category name", + items: { enum: family_category_names }, + minItems: 1, + uniqueItems: true + }, + merchants: { + type: "array", + description: "Filter transactions by merchant name", + items: { enum: family_merchant_names }, + minItems: 1, + uniqueItems: true + }, + tags: { + type: "array", + description: "Filter transactions by tag name", + items: { enum: family_tag_names }, + minItems: 1, + uniqueItems: true + } + } + ) + end + + def call(params = {}) + search_params = params.except("order", "page", "page_size") + + transactions = family.transactions.active.search(search_params).includes( + { entry: :account }, + :category, :merchant, :tags, + transfer_as_outflow: { inflow_transaction: { entry: :account } }, + transfer_as_inflow: { outflow_transaction: { entry: :account } } + ) + + ordered_transactions = params["order"] == "asc" ? transactions.chronological : transactions.reverse_chronological + + # By default, we give a small page size to force the AI to use filters effectively and save on tokens + pagy, transactions = pagy(ordered_transactions, page: params["page"] || 1, limit: 10) + + normalized_transactions = transactions.map do |txn| + entry = txn.entry + { + date: entry.date, + amount: entry.amount.abs, + currency: entry.currency, + formatted_amount: entry.amount_money.abs.format, + classification: entry.amount < 0 ? "income" : "expense", + account: entry.account.name, + category: txn.category&.name, + merchant: txn.merchant&.name, + tags: txn.tags.map(&:name), + is_transfer: txn.transfer.present? + } + end + + { + transactions: normalized_transactions, + total_results: pagy.count, + page: pagy.page, + page_size: 10, + total_pages: pagy.pages + } + end +end diff --git a/app/models/assistant/functions/compare_periods.rb b/app/models/assistant/functions/compare_periods.rb deleted file mode 100644 index 927531d3..00000000 --- a/app/models/assistant/functions/compare_periods.rb +++ /dev/null @@ -1,124 +0,0 @@ -class Assistant::Functions::ComparePeriods - include Assistant::Functions::Toolable - - class << self - def name - "compare_periods" - end - - def description - "Compare financial data between two periods" - end - - def parameters - { - type: "object", - properties: { - period1: { - type: "string", - enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ], - description: "First period for comparison" - }, - period2: { - type: "string", - enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ], - description: "Second period for comparison" - } - }, - required: [ "period1", "period2" ], - additionalProperties: false - } - end - end - - def call(params = {}) - period1 = get_period_from_param(params["period1"]) - period2 = get_period_from_param(params["period2"]) - - income_statement = IncomeStatement.new(family) - period1_data = get_period_data(income_statement, period1) - period2_data = get_period_data(income_statement, period2) - - { - period1: format_period_data(period1_data, params["period1"]), - period2: format_period_data(period2_data, params["period2"]), - differences: calculate_differences(period1_data, period2_data), - currency: family.currency - } - end - - private - - def get_period_from_param(period_param) - case period_param - when "current_month" - Period.current_month - when "previous_month" - Period.previous_month - when "year_to_date" - Period.year_to_date - when "previous_year" - Period.previous_year - else - Period.current_month - end - end - - def get_period_data(income_statement, period) - { - period: period, - income: income_statement.income_totals(period: period), - expenses: income_statement.expense_totals(period: period) - } - end - - def format_period_data(data, period_name) - net_income = data[:income].total - data[:expenses].total - { - name: get_period_name(period_name), - start_date: data[:period].start_date.to_s, - end_date: data[:period].end_date.to_s, - total_income: format_currency(data[:income].total), - total_expenses: format_currency(data[:expenses].total), - net_income: format_currency(net_income) - } - end - - def calculate_differences(period1_data, period2_data) - income_diff = period1_data[:income].total - period2_data[:income].total - expenses_diff = period1_data[:expenses].total - period2_data[:expenses].total - net_income_diff = income_diff - expenses_diff - - { - income: format_currency(income_diff), - income_percent: calculate_percentage_change(income_diff, period2_data[:income].total), - expenses: format_currency(expenses_diff), - expenses_percent: calculate_percentage_change(expenses_diff, period2_data[:expenses].total), - net_income: format_currency(net_income_diff) - } - end - - def calculate_percentage_change(diff, original) - return 0 if original.zero? - (diff / original.to_f * 100).round(2) - end - - def get_period_name(period_param) - case period_param - when "current_month" - "Current Month" - when "previous_month" - "Previous Month" - when "year_to_date" - "Year to Date" - when "previous_year" - "Previous Year" - else - "Custom Period" - end - end - - def format_currency(amount) - Money.new(amount, family.currency).format - end -end diff --git a/app/models/assistant/functions/get_balance_sheet.rb b/app/models/assistant/functions/get_balance_sheet.rb deleted file mode 100644 index dcd6e0e1..00000000 --- a/app/models/assistant/functions/get_balance_sheet.rb +++ /dev/null @@ -1,18 +0,0 @@ -class Assistant::Functions::GetBalanceSheet - include Assistant::Functions::Toolable - - class << self - def name - "get_balance_sheet" - end - - def description - "Get current balance sheet information including net worth, assets, and liabilities" - end - end - - def call(params = {}) - balance_sheet = BalanceSheet.new(family) - balance_sheet.to_ai_readable_hash - end -end diff --git a/app/models/assistant/functions/get_expense_categories.rb b/app/models/assistant/functions/get_expense_categories.rb deleted file mode 100644 index a311a9b5..00000000 --- a/app/models/assistant/functions/get_expense_categories.rb +++ /dev/null @@ -1,91 +0,0 @@ -class Assistant::Functions::GetExpenseCategories - include Assistant::Functions::Toolable - - class << self - def name - "get_expense_categories" - end - - def description - "Get top expense categories for a specific time period" - end - - def parameters - { - type: "object", - properties: { - period: { - type: "string", - enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ], - description: "The time period for the expense categories data" - }, - limit: { - type: "integer", - description: "Number of top categories to return" - } - }, - required: [ "period", "limit" ], - additionalProperties: false - } - end - end - - def call(params = {}) - income_statement = IncomeStatement.new(family) - period = get_period_from_param(params["period"]) - limit = params["limit"] || 5 - - expense_data = income_statement.expense_totals(period: period) - - { - period: format_period(period), - total_expenses: format_currency(expense_data.total), - top_categories: get_top_categories(expense_data, limit), - currency: family.currency - } - end - - private - - def get_period_from_param(period_param) - case period_param - when "current_month" - Period.current_month - when "previous_month" - Period.previous_month - when "year_to_date" - Period.year_to_date - when "previous_year" - Period.previous_year - else - Period.current_month - end - end - - def format_period(period) - { - start_date: period.start_date.to_s, - end_date: period.end_date.to_s - } - end - - def get_top_categories(expense_data, limit) - expense_data.category_totals - .reject { |ct| ct.category.subcategory? } - .sort_by { |ct| -ct.total } - .take(limit) - .map { |ct| format_category(ct) } - end - - def format_category(category_total) - { - name: category_total.category.name, - amount: format_currency(category_total.total), - percentage: category_total.weight.round(2) - } - end - - def format_currency(amount) - Money.new(amount, family.currency).format - end -end diff --git a/app/models/assistant/functions/get_income_statement.rb b/app/models/assistant/functions/get_income_statement.rb deleted file mode 100644 index 5a53a2b2..00000000 --- a/app/models/assistant/functions/get_income_statement.rb +++ /dev/null @@ -1,51 +0,0 @@ -class Assistant::Functions::GetIncomeStatement - include Assistant::Functions::Toolable - - class << self - def name - "get_income_statement" - end - - def description - "Get income statement data for a specific time period" - end - - def parameters - { - type: "object", - properties: { - period: { - type: "string", - enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ], - description: "The time period for the income statement data" - } - }, - required: [ "period" ], - additionalProperties: false - } - end - end - - def call(params = {}) - income_statement = IncomeStatement.new(family) - period = get_period_from_param(params["period"]) - income_statement.to_ai_readable_hash(period: period) - end - - private - - def get_period_from_param(period_param) - case period_param - when "current_month" - Period.current_month - when "previous_month" - Period.previous_month - when "year_to_date" - Period.year_to_date - when "previous_year" - Period.previous_year - else - Period.current_month - end - end -end diff --git a/app/models/assistant/functions/get_transactions.rb b/app/models/assistant/functions/get_transactions.rb deleted file mode 100644 index 1d7657e5..00000000 --- a/app/models/assistant/functions/get_transactions.rb +++ /dev/null @@ -1,115 +0,0 @@ -class Assistant::Functions::GetTransactions - include Assistant::Functions::Toolable - - class << self - def name - "get_transactions" - end - - def description - "Get transactions filtered by date range and/or category" - end - - def parameters - { - type: "object", - properties: { - start_date: { - type: "string", - description: "Start date for transactions in YYYY-MM-DD format" - }, - end_date: { - type: "string", - description: "End date for transactions in YYYY-MM-DD format" - }, - category_name: { - type: "string", - description: "Filter transactions by category name" - }, - limit: { - type: "integer", - description: "Maximum number of transactions to return (defaults to 10)" - } - }, - required: [ "start_date", "end_date", "category_name", "limit" ], - additionalProperties: false - } - end - end - - def call(params = {}) - start_date = parse_date(params["start_date"], 30.days.ago.to_date) - end_date = parse_date(params["end_date"], Date.today) - category_name = params["category_name"] - limit = params["limit"] || 10 - - transactions = fetch_transactions(start_date, end_date, category_name, limit) - category = find_category(category_name) if category_name.present? - - { - period: format_period(start_date, end_date), - transactions: format_transactions(transactions), - count: transactions.size, - currency: family.currency, - search_info: format_search_info(category_name, category) - } - end - - private - - def parse_date(date_string, default) - date_string ? Date.parse(date_string) : default - end - - def fetch_transactions(start_date, end_date, category_name, limit) - transactions = family.transactions.active - .in_period(Period.new(start_date: start_date, end_date: end_date)) - .includes(:account_entry, :category, :merchant) - .order("account_entries.date DESC") - - if category_name.present? && (category = find_category(category_name)) - transactions = transactions.where(category_id: category.id) - end - - transactions.limit(limit) - end - - def find_category(name) - category = family.categories.find_by(name: name) - return category if category - - categories = family.categories.where("LOWER(name) LIKE ?", "%#{name.downcase}%") - categories.first if categories.any? - end - - def format_period(start_date, end_date) - { - start_date: start_date.to_s, - end_date: end_date.to_s - } - end - - def format_transactions(transactions) - transactions.map do |transaction| - entry = transaction.account_entry - { - date: entry.date, - name: entry.name, - amount: format_currency(entry.amount), - category: transaction.category&.name || "Uncategorized", - merchant: transaction.merchant&.name - } - end - end - - def format_search_info(category_name, category) - { - category_query: category_name, - matched_category: category&.name - } - end - - def format_currency(amount) - Money.new(amount, family.currency).format - end -end diff --git a/app/models/assistant/functions/toolable.rb b/app/models/assistant/functions/toolable.rb deleted file mode 100644 index 97402ffb..00000000 --- a/app/models/assistant/functions/toolable.rb +++ /dev/null @@ -1,38 +0,0 @@ -module Assistant::Functions::Toolable - extend ActiveSupport::Concern - - class_methods do - def name - raise NotImplementedError, "Subclasses must implement the name class method" - end - - def description - raise NotImplementedError, "Subclasses must implement the description class method" - end - - def parameters - { - type: "object", - properties: {}, - required: [], - additionalProperties: false - } - end - end - - def call(params = {}) - raise NotImplementedError, "Subclasses must implement the call instance method" - end - - def name - self.class.name - end - - def description - self.class.description - end - - def parameters - self.class.parameters - end -end diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb index 13caa12a..f6b07f16 100644 --- a/app/models/balance_sheet.rb +++ b/app/models/balance_sheet.rb @@ -1,6 +1,5 @@ class BalanceSheet include Monetizable - include Promptable monetize :total_assets, :total_liabilities, :net_worth @@ -74,89 +73,6 @@ class BalanceSheet family.currency end - # AI-friendly representation of balance sheet data - def to_ai_readable_hash - { - net_worth: format_currency(net_worth), - total_assets: format_currency(total_assets), - total_liabilities: format_currency(total_liabilities), - as_of_date: Date.today.to_s, - currency: currency - } - end - - # Detailed summary of the balance sheet for AI - def detailed_summary - asset_groups = account_groups("asset") - liability_groups = account_groups("liability") - - { - asset_breakdown: asset_groups.map do |group| - { - type: group.name, - total: format_currency(group.total), - percentage_of_assets: format_percentage(group.weight), - accounts: group.accounts.map do |account| - { - name: account.name, - balance: format_currency(account.balance), - percentage_of_type: format_percentage(account.weight) - } - end - } - end, - liability_breakdown: liability_groups.map do |group| - { - type: group.name, - total: format_currency(group.total), - percentage_of_liabilities: format_percentage(group.weight), - accounts: group.accounts.map do |account| - { - name: account.name, - balance: format_currency(account.balance), - percentage_of_type: format_percentage(account.weight) - } - end - } - end - } - end - - # Generate financial insights for the balance sheet - def financial_insights - prev_month_networth = previous_month_net_worth - month_change = net_worth - prev_month_networth - month_change_percentage = prev_month_networth.zero? ? 0 : (month_change / prev_month_networth.to_f * 100) - - debt_to_asset_ratio = total_assets.zero? ? 0 : (total_liabilities / total_assets.to_f) - - largest_asset_group = account_groups("asset").max_by(&:total) - largest_liability_group = account_groups("liability").max_by(&:total) - - { - summary: "Your net worth is #{format_currency(net_worth)} as of #{format_date(Date.today)}.", - monthly_change: { - amount: format_currency(month_change), - percentage: format_percentage(month_change_percentage), - trend: month_change > 0 ? "increasing" : (month_change < 0 ? "decreasing" : "stable") - }, - debt_to_asset_ratio: { - ratio: debt_to_asset_ratio.round(2), - interpretation: interpret_debt_to_asset_ratio(debt_to_asset_ratio) - }, - asset_insights: { - largest_type: largest_asset_group&.name || "None", - largest_type_amount: format_currency(largest_asset_group&.total || 0), - largest_type_percentage: format_percentage(largest_asset_group&.weight || 0) - }, - liability_insights: { - largest_type: largest_liability_group&.name || "None", - largest_type_amount: format_currency(largest_liability_group&.total || 0), - largest_type_percentage: format_percentage(largest_liability_group&.weight || 0) - } - } - end - private ClassificationGroup = Struct.new(:key, :display_name, :icon, :account_groups, keyword_init: true) AccountGroup = Struct.new(:key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, keyword_init: true) @@ -176,26 +92,4 @@ class BalanceSheet .group(:classification, :accountable_type, :id) .to_a end - - # Calculate the net worth from the previous month - def previous_month_net_worth - # Here we'd ideally fetch historical data - # For now, we'll estimate it using the current net worth - # In a real implementation, you might use a time series or snapshot - net_worth * 0.97 # Assume 3% growth for demo purposes - end - - # Provide an interpretation of the debt-to-asset ratio - def interpret_debt_to_asset_ratio(ratio) - case ratio - when 0...0.3 - "Your debt-to-asset ratio is low, which is generally considered financially healthy." - when 0.3...0.5 - "Your debt-to-asset ratio is moderate, which is generally manageable." - when 0.5...0.8 - "Your debt-to-asset ratio is somewhat high. You might want to focus on reducing debt." - else - "Your debt-to-asset ratio is high. Consider a debt reduction strategy." - end - end end diff --git a/app/models/concerns/promptable.rb b/app/models/concerns/promptable.rb deleted file mode 100644 index dbd66c29..00000000 --- a/app/models/concerns/promptable.rb +++ /dev/null @@ -1,61 +0,0 @@ -module Promptable - extend ActiveSupport::Concern - - class_methods do - def openai_client - api_key = ENV.fetch("OPENAI_ACCESS_TOKEN", Setting.openai_access_token) - - return nil unless api_key.present? - - OpenAI::Client.new(access_token: api_key) - end - end - - # Convert model data to a format that's readable by AI - def to_ai_readable_hash - raise NotImplementedError, "#{self.class} must implement to_ai_readable_hash" - end - - # Provide detailed financial summary for AI queries - def detailed_summary - raise NotImplementedError, "#{self.class} must implement detailed_summary" - end - - # Generate financial insights and analysis - def financial_insights - raise NotImplementedError, "#{self.class} must implement financial_insights" - end - - # Format all data for AI in a structured way - def to_ai_response(include_insights: true) - response = { - data: to_ai_readable_hash, - details: detailed_summary - } - - response[:insights] = financial_insights if include_insights - response - end - - private - - def openai_client - self.class.openai_client - end - - # Format currency values consistently for AI display - def format_currency(amount, currency = family.currency) - Money.new(amount, currency).format - end - - # Format percentage values consistently for AI display - def format_percentage(value) - return "0.00%" if value.nil? || value.zero? - "#{value.round(2)}%" - end - - # Format date values consistently for AI display - def format_date(date) - date.strftime("%B %d, %Y") - end -end diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index e54e9dbb..fba114e4 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -1,6 +1,5 @@ class IncomeStatement include Monetizable - include Promptable monetize :median_expense, :median_income @@ -54,141 +53,6 @@ class IncomeStatement family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0 end - # AI-friendly representation of income statement data - def to_ai_readable_hash(period: Period.current_month) - expense_data = expense_totals(period: period) - income_data = income_totals(period: period) - - { - period: { - start_date: period.start_date.to_s, - end_date: period.end_date.to_s - }, - total_income: format_currency(income_data.total), - total_expenses: format_currency(expense_data.total), - net_income: format_currency(income_data.total - expense_data.total), - savings_rate: calculate_savings_rate(income_data.total, expense_data.total), - currency: family.currency - } - end - - # Detailed summary of income statement for AI - def detailed_summary(period: Period.current_month) - expense_data = expense_totals(period: period) - income_data = income_totals(period: period) - - { - period_info: { - name: period_name(period), - start_date: format_date(period.start_date), - end_date: format_date(period.end_date), - days: (period.end_date - period.start_date).to_i + 1 - }, - income: { - total: format_currency(income_data.total), - categories: income_data.category_totals - .reject { |ct| ct.category.subcategory? } - .sort_by { |ct| -ct.total } - .map do |ct| - { - name: ct.category.name, - amount: format_currency(ct.total), - percentage: format_percentage(ct.weight) - } - end - }, - expenses: { - total: format_currency(expense_data.total), - categories: expense_data.category_totals - .reject { |ct| ct.category.subcategory? } - .sort_by { |ct| -ct.total } - .map do |ct| - { - name: ct.category.name, - amount: format_currency(ct.total), - percentage: format_percentage(ct.weight) - } - end - }, - savings: { - amount: format_currency(income_data.total - expense_data.total), - rate: format_percentage(calculate_savings_rate(income_data.total, expense_data.total)) - } - } - end - - # Generate financial insights for income statement - def financial_insights(period: Period.current_month) - expense_data = expense_totals(period: period) - income_data = income_totals(period: period) - - # Compare with previous period - prev_period = get_previous_period(period) - prev_expense_data = expense_totals(period: prev_period) - prev_income_data = income_totals(period: prev_period) - - # Calculate changes - income_change = income_data.total - prev_income_data.total - expense_change = expense_data.total - prev_expense_data.total - - # Calculate percentages - income_change_pct = prev_income_data.total.zero? ? 0 : (income_change / prev_income_data.total.to_f * 100) - expense_change_pct = prev_expense_data.total.zero? ? 0 : (expense_change / prev_expense_data.total.to_f * 100) - - # Find top categories - top_expense_categories = expense_data.category_totals - .reject { |ct| ct.category.subcategory? } - .sort_by { |ct| -ct.total } - .take(3) - - top_income_categories = income_data.category_totals - .reject { |ct| ct.category.subcategory? } - .sort_by { |ct| -ct.total } - .take(3) - - current_savings_rate = calculate_savings_rate(income_data.total, expense_data.total) - previous_savings_rate = calculate_savings_rate(prev_income_data.total, prev_expense_data.total) - - { - summary: "For #{period_name(period)}, your net income is #{format_currency(income_data.total - expense_data.total)} with a savings rate of #{format_percentage(current_savings_rate)}.", - period_comparison: { - previous_period: period_name(prev_period), - income_change: { - amount: format_currency(income_change), - percentage: format_percentage(income_change_pct), - trend: income_change > 0 ? "increasing" : (income_change < 0 ? "decreasing" : "stable") - }, - expense_change: { - amount: format_currency(expense_change), - percentage: format_percentage(expense_change_pct), - trend: expense_change > 0 ? "increasing" : (expense_change < 0 ? "decreasing" : "stable") - }, - savings_rate_change: format_percentage(current_savings_rate - previous_savings_rate) - }, - expense_insights: { - top_categories: top_expense_categories.map do |ct| - { - name: ct.category.name, - amount: format_currency(ct.total), - percentage: format_percentage(ct.weight) - } - end, - daily_average: format_currency(expense_data.total / period.days), - monthly_estimate: format_currency(expense_data.total * (30.0 / period.days)) - }, - income_insights: { - top_sources: top_income_categories.map do |ct| - { - name: ct.category.name, - amount: format_currency(ct.total), - percentage: format_percentage(ct.weight) - } - end, - monthly_estimate: format_currency(income_data.total * (30.0 / period.days)) - } - } - end - private ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?) PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals) @@ -255,40 +119,4 @@ class IncomeStatement def monetizable_currency family.currency end - - def calculate_savings_rate(total_income, total_expenses) - return 0 if total_income.zero? - savings = total_income - total_expenses - rate = (savings / total_income.to_f) * 100 - rate.round(2) - end - - # Get previous period for comparison - def get_previous_period(period) - if period.is_a?(Period) - # For custom periods, create a period of same length ending right before this period starts - length = (period.end_date - period.start_date).to_i - Period.new(start_date: period.start_date - length.days - 1.day, end_date: period.start_date - 1.day) - else - # Default to previous month - current_month = Date.today.beginning_of_month..Date.today.end_of_month - previous_month = 1.month.ago.beginning_of_month..1.month.ago.end_of_month - Period.new(start_date: previous_month.begin, end_date: previous_month.end) - end - end - - # Get a human-readable name for a period - def period_name(period) - if period == Period.current_month - "Current Month" - elsif period == Period.previous_month - "Previous Month" - elsif period == Period.year_to_date - "Year to Date" - elsif period == Period.previous_year - "Previous Year" - else - "#{format_date(period.start_date)} to #{format_date(period.end_date)}" - end - end end diff --git a/app/views/assistant_messages/_assistant_message.html.erb b/app/views/assistant_messages/_assistant_message.html.erb index 98442a78..3aa193a2 100644 --- a/app/views/assistant_messages/_assistant_message.html.erb +++ b/app/views/assistant_messages/_assistant_message.html.erb @@ -11,6 +11,10 @@
Tool Calls
+Function:
+<%= tool_call.function_name %>
+Arguments:
+<%= tool_call.function_arguments %>+
Failed to generate response. Please try again.
+ + <%= button_to retry_chat_path(chat), method: :post, class: "btn btn--primary" do %> + Retry + <% end %> +