Assistant function base implementation
This commit is contained in:
@@ -115,7 +115,7 @@
|
||||
.prose--ai-chat {
|
||||
@apply break-words max-w-[300px];
|
||||
|
||||
p {
|
||||
p, li {
|
||||
@apply text-sm text-primary;
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -13,5 +13,5 @@ class Account::Transaction < ApplicationRecord
|
||||
def search(params)
|
||||
Account::TransactionSearch.new(params).build_query(all)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
75
app/models/assistant/function.rb
Normal file
75
app/models/assistant/function.rb
Normal 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
|
||||
@@ -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)
|
||||
122
app/models/assistant/function/get_balance_sheet.rb
Normal file
122
app/models/assistant/function/get_balance_sheet.rb
Normal 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
|
||||
221
app/models/assistant/function/get_income_statement.rb
Normal file
221
app/models/assistant/function/get_income_statement.rb
Normal 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
|
||||
166
app/models/assistant/function/get_transactions.rb
Normal file
166
app/models/assistant/function/get_transactions.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
19
app/views/assistant_messages/_tool_calls.html.erb
Normal file
19
app/views/assistant_messages/_tool_calls.html.erb
Normal 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>
|
||||
9
app/views/chats/_error.html.erb
Normal file
9
app/views/chats/_error.html.erb
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user