Assistant function base implementation

This commit is contained in:
Zach Gollwitzer
2025-03-25 20:14:18 -04:00
parent d6b6f02126
commit 4f7e4e40d8
23 changed files with 653 additions and 808 deletions

View File

@@ -115,7 +115,7 @@
.prose--ai-chat {
@apply break-words max-w-[300px];
p {
p, li {
@apply text-sm text-primary;
}

View File

@@ -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])

View File

@@ -13,5 +13,5 @@ class Account::Transaction < ApplicationRecord
def search(params)
Account::TransactionSearch.new(params).build_query(all)
end
end
end
end

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -11,6 +11,10 @@
<div class="prose prose--ai-chat"><%= markdown(assistant_message.content) %></div>
</details>
<% else %>
<% if assistant_message.chat.debug_mode? && assistant_message.tool_calls.any? %>
<%= render "assistant_messages/tool_calls", message: assistant_message %>
<% end %>
<div class="flex items-start mb-6">
<%= render "chats/ai_avatar" %>
<div class="prose prose--ai-chat"><%= markdown(assistant_message.content) %></div>

View File

@@ -0,0 +1,19 @@
<%# locals: (message:) %>
<details class="my-2 group mb-4">
<summary class="text-secondary text-xs cursor-pointer flex items-center gap-2">
<%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-secondary w-5" %>
<p>Tool Calls</p>
</summary>
<div class="mt-2">
<% message.tool_calls.each do |tool_call| %>
<div class="bg-blue-50 border-blue-200 px-3 py-2 rounded-lg border mb-2">
<p class="text-secondary text-xs">Function:</p>
<p class="text-primary text-sm font-mono"><%= tool_call.function_name %></p>
<p class="text-secondary text-xs mt-2">Arguments:</p>
<pre class="text-primary text-sm font-mono whitespace-pre-wrap"><%= tool_call.function_arguments %></pre>
</div>
<% end %>
</div>
</details>

View File

@@ -0,0 +1,9 @@
<%# locals: (chat:) %>
<div id="chat-error" class="flex items-center justify-between gap-2 px-3 py-2 bg-red-100 border border-red-500 rounded-lg">
<p class="text-xs text-red-500">Failed to generate response. Please try again.</p>
<%= button_to retry_chat_path(chat), method: :post, class: "btn btn--primary" do %>
<span>Retry</span>
<% end %>
</div>

View File

@@ -8,9 +8,9 @@
<%= render "chats/chat_nav", chat: @chat %>
</div>
<div id="messages" class="grow overflow-y-auto p-4" data-chat-target="messages">
<div id="messages" class="grow overflow-y-auto p-4 space-y-6" data-chat-target="messages">
<% if @chat.conversation_messages.any? %>
<% @chat.conversation_messages.each do |message| %>
<% @chat.conversation_messages.ordered.each do |message| %>
<%= render message %>
<% end %>
<% else %>
@@ -18,6 +18,10 @@
<%= render "chats/ai_greeting", context: "chat" %>
</div>
<% end %>
<% if @chat.error.present? %>
<%= render "chats/error", chat: @chat %>
<% end %>
</div>
<div class="p-4">

View File

@@ -14,6 +14,10 @@ Rails.application.routes.draw do
# AI chats
resources :chats do
resources :messages, only: :create
member do
post :retry
end
end
get "changelog", to: "pages#changelog"

View File

@@ -15,14 +15,8 @@ class ChatTest < ActiveSupport::TestCase
test "user sees assistant and user messages in normal mode" do
chat = chats(:one)
assert_equal 4, chat.conversation_messages.count
end
test "assistant sees all messages except for debug messages" do
chat = chats(:one)
assert_equal chat.messages.count - 1, chat.conversation_messages.count
end
assert_equal 3, chat.conversation_messages.count
end
test "creates with initial message" do
prompt = "Test prompt"