mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-25 08:09:38 +02:00
Personal finance AI (v1) (#2022)
* AI sidebar * Add chat and message models with associations * Implement AI chat functionality with sidebar and messaging system - Add chat and messages controllers - Create chat and message views - Implement chat-related routes - Add message broadcasting and user interactions - Update application layout to support chat sidebar - Enhance user model with initials method * Refactor AI sidebar with enhanced chat menu and interactions - Update sidebar layout with dynamic width and improved responsiveness - Add new chat menu Stimulus controller for toggling between chat and chat list views - Improve chat list display with recent chats and empty state - Extract AI avatar to a partial for reusability - Enhance message display and interaction styling - Add more contextual buttons and interaction hints * Improve chat scroll behavior and message styling - Refactor chat scroll functionality with Stimulus controller - Optimize message scrolling in chat views - Update message styling for better visual hierarchy - Enhance chat container layout with flex and auto-scroll - Simplify message rendering across different chat views * Extract AI avatar to a shared partial for consistent styling - Refactor AI avatar rendering across chat views - Replace hardcoded avatar markup with a reusable partial - Simplify avatar display in chats and messages views * Update sidebar controller to handle right panel width dynamically - Add conditional width class for right sidebar panel - Ensure consistent sidebar toggle behavior for both left and right panels - Use specific width class for right panel (w-[375px]) * Refactor chat form and AI greeting with flexible partials - Extract message form to a reusable partial with dynamic context support - Create flexible AI greeting partial for consistent welcome messages - Simplify chat and sidebar views by leveraging new partials - Add support for different form scenarios (chat, new chat, sidebar) - Improve code modularity and reduce duplication * Add chat clearing functionality with dynamic menu options - Implement clear chat action in ChatsController - Add clear chat route to support clearing messages - Update AI sidebar with dropdown menu for chat actions - Preserve system message when clearing chat - Enhance chat interaction with new menu options * Add frontmatter to project structure documentation - Create initial frontmatter for structure.mdc file - Include description and configuration options - Prepare for potential dynamic documentation rendering * Update general project rules with additional guidelines - Add rule for using `Current.family` instead of `current_family` - Include new guidelines for testing, API routes, and solution approach - Expand project-specific rules for more consistent development practices * Add OpenAI gem and AI-friendly data representations - Add `ruby-openai` gem for AI integration - Implement `to_ai_readable_hash` methods in BalanceSheet and IncomeStatement - Include Promptable module in both models - Add savings rate calculation method in IncomeStatement - Prepare financial models for AI-powered insights and interactions * Enhance AI Financial Assistant with Advanced Querying and Debugging Capabilities - Implement comprehensive AI financial query system with function-based interactions - Add detailed debug logging for AI responses and function calls - Extend BalanceSheet and IncomeStatement models with AI-friendly methods - Create robust error handling and fallback mechanisms for AI queries - Update chat and message views to support debug mode and enhanced rendering - Add AI query routes and initial test coverage for financial assistant * Refactor AI sidebar and chat layout with improved structure and comments - Remove inline AI chat from application layout - Enhance AI sidebar with more semantic HTML structure - Add descriptive comments to clarify different sections of chat view - Improve flex layout and scrolling behavior in chat messages container - Optimize message rendering with more explicit class names and structure * Add Markdown rendering support for AI chat messages - Implement `markdown` helper method in ApplicationHelper using Redcarpet - Update message view to render AI messages with Markdown formatting - Add comprehensive Markdown rendering options (tables, code blocks, links) - Enhance AI Financial Assistant prompt to encourage Markdown usage - Remove commented Markdown CSS in Tailwind application stylesheet * Missing comma * Enhance AI response processing with chat history context * Improve AI debug logging with payload size limits and internal message flag * Enhance AI chat interaction with improved thinking indicator and scrolling behavior * Add AI consent and enable/disable functionality for AI chat * Upgrade Biome and refactor JavaScript template literals - Update @biomejs/biome to latest version with caret (^) notation - Refactor AI query and chat controllers to use template literals - Standardize npm scripts formatting in package.json * Add beta testing usage note to AI consent modal * Update test fixtures and configurations for AI chat functionality - Add family association to chat fixtures and tests - Set consistent password digest for test users - Enable AI for test users - Add OpenAI access token for test environment - Update chat and user model tests to include family context * Simplify data model and get tests passing * Remove structure.mdc from version control * Integrate AI chat styles into existing prose pattern * Match Figma design spec, implement Turbo frames and actions for chats controller * AI rules refresh * Consolidate Stimulus controllers, thinking state, controllers, and views * Naming, domain alignment * Reset migrations * Improve data model to support tool calls and message types * Tool calling tests and fixtures * Tool call implementation and test * Get assistant test working again * Test updates * Process tool calls within provider * Chat UI back to working state again * Remove stale code * Tests passing * Update openai class naming to avoid conflicts * Reconfigure test env * Rebuild gemfile * Fix naming conflicts for ChatResponse * Message styles * Use OpenAI conversation state management * Assistant function base implementation * Add back thinking messages, clean up error handling for chat * Fix sync error when security price has bad data from provider * Add balance sheet function to assistant * Add better function calling error visibility * Add income statement function * Simplify and clean up "thinking" interactions with Turbo frames * Remove stale data definitions from functions * Ensure VCR fixtures working with latest code * basic stream implementation * Get streaming working * Make AI sidebar wider when left sidebar is collapsed * Get tests working with streaming responses * Centralize provider error handling * Provider data boundaries --------- Co-authored-by: Josh Pigford <josh@joshpigford.com>
This commit is contained in:
parent
8e6b81af77
commit
2f6b11c18f
126 changed files with 3576 additions and 462 deletions
|
@ -2,15 +2,17 @@ module Account::Chartable
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance)
|
||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil)
|
||||
raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
|
||||
|
||||
series_interval = interval || period.interval
|
||||
|
||||
balances = Account::Balance.find_by_sql([
|
||||
balance_series_query,
|
||||
{
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date,
|
||||
interval: period.interval,
|
||||
interval: series_interval,
|
||||
target_currency: currency
|
||||
}
|
||||
])
|
||||
|
@ -33,7 +35,7 @@ module Account::Chartable
|
|||
Series.new(
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date,
|
||||
interval: period.interval,
|
||||
interval: series_interval,
|
||||
trend: Trend.new(
|
||||
current: Money.new(balance_value_for(balances.last, view) || 0, currency),
|
||||
previous: Money.new(balance_value_for(balances.first, view) || 0, currency),
|
||||
|
@ -124,11 +126,12 @@ module Account::Chartable
|
|||
classification == "asset" ? "up" : "down"
|
||||
end
|
||||
|
||||
def balance_series(period: Period.last_30_days, view: :balance)
|
||||
def balance_series(period: Period.last_30_days, view: :balance, interval: nil)
|
||||
self.class.where(id: self.id).balance_series(
|
||||
currency: currency,
|
||||
period: period,
|
||||
view: view,
|
||||
interval: interval,
|
||||
favorable_direction: favorable_direction
|
||||
)
|
||||
end
|
||||
|
|
|
@ -2,9 +2,9 @@ module Account::Transaction::Provided
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
def fetch_enrichment_info
|
||||
return nil unless Providers.synth # Only Synth can provide this data
|
||||
return nil unless provider
|
||||
|
||||
response = Providers.synth.enrich_transaction(
|
||||
response = provider.enrich_transaction(
|
||||
entry.name,
|
||||
amount: entry.amount,
|
||||
date: entry.date
|
||||
|
@ -12,4 +12,9 @@ module Account::Transaction::Provided
|
|||
|
||||
response.data
|
||||
end
|
||||
|
||||
private
|
||||
def provider
|
||||
Provider::Registry.get_provider(:synth)
|
||||
end
|
||||
end
|
||||
|
|
178
app/models/assistant.rb
Normal file
178
app/models/assistant.rb
Normal file
|
@ -0,0 +1,178 @@
|
|||
# Orchestrates LLM interactions for chat conversations by:
|
||||
# - Streaming generic provider responses
|
||||
# - Persisting messages and tool calls
|
||||
# - Broadcasting updates to chat UI
|
||||
# - Handling provider errors
|
||||
class Assistant
|
||||
include Provided
|
||||
|
||||
attr_reader :chat
|
||||
|
||||
class << self
|
||||
def for_chat(chat)
|
||||
new(chat)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(chat)
|
||||
@chat = chat
|
||||
end
|
||||
|
||||
def streamer(model)
|
||||
assistant_message = AssistantMessage.new(
|
||||
chat: chat,
|
||||
content: "",
|
||||
ai_model: model
|
||||
)
|
||||
|
||||
proc do |chunk|
|
||||
case chunk.type
|
||||
when "output_text"
|
||||
stop_thinking
|
||||
assistant_message.content += chunk.data
|
||||
assistant_message.save!
|
||||
when "function_request"
|
||||
update_thinking("Analyzing your data to assist you with your question...")
|
||||
when "response"
|
||||
stop_thinking
|
||||
assistant_message.ai_model = chunk.data.model
|
||||
combined_tool_calls = chunk.data.functions.map do |tc|
|
||||
ToolCall::Function.new(
|
||||
provider_id: tc.id,
|
||||
provider_call_id: tc.call_id,
|
||||
function_name: tc.name,
|
||||
function_arguments: tc.arguments,
|
||||
function_result: tc.result
|
||||
)
|
||||
end
|
||||
|
||||
assistant_message.tool_calls = combined_tool_calls
|
||||
assistant_message.save!
|
||||
chat.update!(latest_assistant_response_id: chunk.data.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def respond_to(message)
|
||||
chat.clear_error
|
||||
sleep artificial_thinking_delay
|
||||
|
||||
provider = get_model_provider(message.ai_model)
|
||||
|
||||
provider.chat_response(
|
||||
message,
|
||||
instructions: instructions,
|
||||
available_functions: functions,
|
||||
streamer: streamer(message.ai_model)
|
||||
)
|
||||
rescue => e
|
||||
chat.add_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
def update_thinking(thought)
|
||||
chat.broadcast_update target: "thinking-indicator", partial: "chats/thinking_indicator", locals: { chat: chat, message: thought }
|
||||
end
|
||||
|
||||
def stop_thinking
|
||||
chat.broadcast_remove target: "thinking-indicator"
|
||||
end
|
||||
|
||||
def process_response_artifacts(data)
|
||||
messages = data.messages.map do |message|
|
||||
AssistantMessage.new(
|
||||
chat: chat,
|
||||
content: message.content,
|
||||
provider_id: message.id,
|
||||
ai_model: data.model,
|
||||
tool_calls: data.functions.map do |fn|
|
||||
ToolCall::Function.new(
|
||||
provider_id: fn.id,
|
||||
provider_call_id: fn.call_id,
|
||||
function_name: fn.name,
|
||||
function_arguments: fn.arguments,
|
||||
function_result: fn.result
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
messages.each(&:save!)
|
||||
end
|
||||
|
||||
def instructions
|
||||
<<~PROMPT
|
||||
## Your identity
|
||||
|
||||
You are a financial assistant for an open source personal finance application called "Maybe", which is short for "Maybe Finance".
|
||||
|
||||
## Your purpose
|
||||
|
||||
You help users understand their financial data by answering questions about their accounts,
|
||||
transactions, income, expenses, net worth, and more.
|
||||
|
||||
## Your rules
|
||||
|
||||
Follow all rules below at all times.
|
||||
|
||||
### General rules
|
||||
|
||||
- Provide ONLY the most important numbers and insights
|
||||
- Eliminate all unnecessary words and context
|
||||
- Ask follow-up questions to keep the conversation going. Help educate the user about their own data and entice them to ask more questions.
|
||||
- Do NOT add introductions or conclusions
|
||||
- Do NOT apologize or explain limitations
|
||||
|
||||
### Formatting rules
|
||||
|
||||
- Format all responses in markdown
|
||||
- Format all monetary values according to the user's preferred currency
|
||||
|
||||
#### User's preferred currency
|
||||
|
||||
Maybe is a multi-currency app where each user has a "preferred currency" setting.
|
||||
|
||||
When no currency is specified, use the user's preferred currency for formatting and displaying monetary values.
|
||||
|
||||
- Symbol: #{preferred_currency.symbol}
|
||||
- ISO code: #{preferred_currency.iso_code}
|
||||
- Default precision: #{preferred_currency.default_precision}
|
||||
- Default format: #{preferred_currency.default_format}
|
||||
- Separator: #{preferred_currency.separator}
|
||||
- Delimiter: #{preferred_currency.delimiter}
|
||||
|
||||
### Rules about financial advice
|
||||
|
||||
You are NOT a licensed financial advisor and therefore, you should not provide any financial advice. Instead,
|
||||
you should focus on educating the user about personal finance and their own data so they can make informed decisions.
|
||||
|
||||
- Do not provide financial and/or investment advice
|
||||
- Do not suggest investments or financial products
|
||||
- Do not make assumptions about the user's financial situation. Use the functions available to get the data you need.
|
||||
|
||||
### Function calling rules
|
||||
|
||||
- Use the functions available to you to get user financial data and enhance your responses
|
||||
- For functions that require dates, use the current date as your reference point: #{Date.current}
|
||||
- If you suspect that you do not have enough data to 100% accurately answer, be transparent about it and state exactly what
|
||||
the data you're presenting represents and what context it is in (i.e. date range, account, etc.)
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def functions
|
||||
[
|
||||
Assistant::Function::GetTransactions.new(chat.user),
|
||||
Assistant::Function::GetAccounts.new(chat.user),
|
||||
Assistant::Function::GetBalanceSheet.new(chat.user),
|
||||
Assistant::Function::GetIncomeStatement.new(chat.user)
|
||||
]
|
||||
end
|
||||
|
||||
def preferred_currency
|
||||
Money::Currency.new(chat.user.family.currency)
|
||||
end
|
||||
|
||||
def artificial_thinking_delay
|
||||
1
|
||||
end
|
||||
end
|
83
app/models/assistant/function.rb
Normal file
83
app/models/assistant/function.rb
Normal file
|
@ -0,0 +1,83 @@
|
|||
class Assistant::Function
|
||||
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
|
||||
|
||||
# To save tokens, we provide the AI metadata about the series and a flat array of
|
||||
# raw, formatted values which it can infer dates from
|
||||
def to_ai_time_series(series)
|
||||
{
|
||||
start_date: series.start_date,
|
||||
end_date: series.end_date,
|
||||
interval: series.interval,
|
||||
values: series.values.map { |v| v.trend.current.format }
|
||||
}
|
||||
end
|
||||
end
|
40
app/models/assistant/function/get_accounts.rb
Normal file
40
app/models/assistant/function/get_accounts.rb
Normal file
|
@ -0,0 +1,40 @@
|
|||
class Assistant::Function::GetAccounts < Assistant::Function
|
||||
class << self
|
||||
def name
|
||||
"get_accounts"
|
||||
end
|
||||
|
||||
def description
|
||||
"Use this to see what accounts the user has along with their current and historical balances"
|
||||
end
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
{
|
||||
as_of_date: Date.current,
|
||||
accounts: family.accounts.includes(:balances).map do |account|
|
||||
{
|
||||
name: account.name,
|
||||
balance: account.balance,
|
||||
currency: account.currency,
|
||||
balance_formatted: account.balance_money.format,
|
||||
classification: account.classification,
|
||||
type: account.accountable_type,
|
||||
start_date: account.start_date,
|
||||
is_plaid_linked: account.plaid_account_id.present?,
|
||||
is_active: account.is_active,
|
||||
historical_balances: historical_balances(account)
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def historical_balances(account)
|
||||
start_date = [ account.start_date, 5.years.ago.to_date ].max
|
||||
period = Period.custom(start_date: start_date, end_date: Date.current)
|
||||
balance_series = account.balance_series(period: period, interval: "1 month")
|
||||
|
||||
to_ai_time_series(balance_series)
|
||||
end
|
||||
end
|
73
app/models/assistant/function/get_balance_sheet.rb
Normal file
73
app/models/assistant/function/get_balance_sheet.rb
Normal file
|
@ -0,0 +1,73 @@
|
|||
class Assistant::Function::GetBalanceSheet < Assistant::Function
|
||||
include ActiveSupport::NumberHelper
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_balance_sheet"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Use this to get the user's balance sheet with varying amounts of historical data.
|
||||
|
||||
This is great for answering questions like:
|
||||
- What is the user's net worth? What is it composed of?
|
||||
- How has the user's wealth changed over time?
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
observation_start_date = [ 5.years.ago.to_date, family.oldest_entry_date ].max
|
||||
|
||||
period = Period.custom(start_date: observation_start_date, end_date: Date.current)
|
||||
|
||||
{
|
||||
as_of_date: Date.current,
|
||||
oldest_account_start_date: family.oldest_entry_date,
|
||||
currency: family.currency,
|
||||
net_worth: {
|
||||
current: family.balance_sheet.net_worth_money.format,
|
||||
monthly_history: historical_data(period)
|
||||
},
|
||||
assets: {
|
||||
current: family.balance_sheet.total_assets_money.format,
|
||||
monthly_history: historical_data(period, classification: "asset")
|
||||
},
|
||||
liabilities: {
|
||||
current: family.balance_sheet.total_liabilities_money.format,
|
||||
monthly_history: historical_data(period, classification: "liability")
|
||||
},
|
||||
insights: insights_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def historical_data(period, classification: nil)
|
||||
scope = family.accounts.active
|
||||
scope = scope.where(classification: classification) if classification.present?
|
||||
|
||||
if period.start_date == Date.current
|
||||
[]
|
||||
else
|
||||
balance_series = scope.balance_series(
|
||||
currency: family.currency,
|
||||
period: period,
|
||||
interval: "1 month",
|
||||
favorable_direction: "up",
|
||||
)
|
||||
|
||||
to_ai_time_series(balance_series)
|
||||
end
|
||||
end
|
||||
|
||||
def insights_data
|
||||
assets = family.balance_sheet.total_assets
|
||||
liabilities = family.balance_sheet.total_liabilities
|
||||
ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f)
|
||||
|
||||
{
|
||||
debt_to_asset_ratio: number_to_percentage(ratio * 100, precision: 0)
|
||||
}
|
||||
end
|
||||
end
|
125
app/models/assistant/function/get_income_statement.rb
Normal file
125
app/models/assistant/function/get_income_statement.rb
Normal file
|
@ -0,0 +1,125 @@
|
|||
class Assistant::Function::GetIncomeStatement < Assistant::Function
|
||||
include ActiveSupport::NumberHelper
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_income_statement"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Use this to get income and expense insights by category, for a specific time period
|
||||
|
||||
This is great for answering questions like:
|
||||
- What is the user's net income for the current month?
|
||||
- What are the user's spending habits?
|
||||
- How much income or spending did the user have over a specific time period?
|
||||
|
||||
Simple example:
|
||||
|
||||
```
|
||||
get_income_statement({
|
||||
start_date: "2024-01-01",
|
||||
end_date: "2024-12-31"
|
||||
})
|
||||
```
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
period = Period.custom(start_date: Date.parse(params["start_date"]), end_date: Date.parse(params["end_date"]))
|
||||
income_data = family.income_statement.income_totals(period: period)
|
||||
expense_data = family.income_statement.expense_totals(period: period)
|
||||
|
||||
{
|
||||
currency: family.currency,
|
||||
period: {
|
||||
start_date: period.start_date,
|
||||
end_date: period.end_date
|
||||
},
|
||||
income: {
|
||||
total: format_money(income_data.total),
|
||||
by_category: to_ai_category_totals(income_data.category_totals)
|
||||
},
|
||||
expense: {
|
||||
total: format_money(expense_data.total),
|
||||
by_category: to_ai_category_totals(expense_data.category_totals)
|
||||
},
|
||||
insights: get_insights(income_data, expense_data)
|
||||
}
|
||||
end
|
||||
|
||||
def params_schema
|
||||
build_schema(
|
||||
required: [ "start_date", "end_date" ],
|
||||
properties: {
|
||||
start_date: {
|
||||
type: "string",
|
||||
description: "Start date for aggregation period in YYYY-MM-DD format"
|
||||
},
|
||||
end_date: {
|
||||
type: "string",
|
||||
description: "End date for aggregation period in YYYY-MM-DD format"
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def format_money(value)
|
||||
Money.new(value, family.currency).format
|
||||
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
|
||||
|
||||
def to_ai_category_totals(category_totals)
|
||||
hierarchical_groups = category_totals.group_by { |ct| ct.category.parent_id }.then do |grouped|
|
||||
root_category_totals = grouped[nil] || []
|
||||
|
||||
root_category_totals.each_with_object({}) do |ct, hash|
|
||||
subcategory_totals = ct.category.name == "Uncategorized" ? [] : (grouped[ct.category.id] || [])
|
||||
hash[ct.category.name] = {
|
||||
category_total: ct,
|
||||
subcategory_totals: subcategory_totals
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
hierarchical_groups.sort_by { |name, data| -data.dig(:category_total).total }.map do |name, data|
|
||||
{
|
||||
name: name,
|
||||
total: format_money(data.dig(:category_total).total),
|
||||
percentage_of_total: number_to_percentage(data.dig(:category_total).weight, precision: 1),
|
||||
subcategory_totals: data.dig(:subcategory_totals).map do |st|
|
||||
{
|
||||
name: st.category.name,
|
||||
total: format_money(st.total),
|
||||
percentage_of_total: number_to_percentage(st.weight, precision: 1)
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def get_insights(income_data, expense_data)
|
||||
net_income = income_data.total - expense_data.total
|
||||
savings_rate = calculate_savings_rate(income_data.total, expense_data.total)
|
||||
median_monthly_income = family.income_statement.median_income
|
||||
median_monthly_expenses = family.income_statement.median_expense
|
||||
avg_monthly_expenses = family.income_statement.avg_expense
|
||||
|
||||
{
|
||||
net_income: format_money(net_income),
|
||||
savings_rate: number_to_percentage(savings_rate),
|
||||
median_monthly_income: format_money(median_monthly_income),
|
||||
median_monthly_expenses: format_money(median_monthly_expenses),
|
||||
avg_monthly_expenses: format_money(avg_monthly_expenses)
|
||||
}
|
||||
end
|
||||
end
|
185
app/models/assistant/function/get_transactions.rb
Normal file
185
app/models/assistant/function/get_transactions.rb
Normal file
|
@ -0,0 +1,185 @@
|
|||
class Assistant::Function::GetTransactions < Assistant::Function
|
||||
include Pagy::Backend
|
||||
|
||||
class << self
|
||||
def default_page_size
|
||||
50
|
||||
end
|
||||
|
||||
def name
|
||||
"get_transactions"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Use this to search user's transactions by using various optional filters.
|
||||
|
||||
This function is great for things like:
|
||||
- Finding specific transactions
|
||||
- Getting basic stats about a small group of transactions
|
||||
|
||||
This function is not great for:
|
||||
- Large time periods (use the get_income_statement function for this)
|
||||
|
||||
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 #{default_page_size})
|
||||
- `total_results`: The total number of results for the given filters
|
||||
- `total_income`: The total income for the given filters
|
||||
- `total_expenses`: The total expenses for the given filters
|
||||
|
||||
Simple example (transactions from the last 30 days):
|
||||
|
||||
```
|
||||
get_transactions({
|
||||
page: 1,
|
||||
start_date: "#{30.days.ago.to_date}",
|
||||
end_date: "#{Date.current}"
|
||||
})
|
||||
```
|
||||
|
||||
More complex example (various filters):
|
||||
|
||||
```
|
||||
get_transactions({
|
||||
page: 1,
|
||||
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: {
|
||||
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")
|
||||
|
||||
transactions_query = family.transactions.active.search(search_params)
|
||||
pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological
|
||||
|
||||
# By default, we give a small page size to force the AI to use filters effectively and save on tokens
|
||||
pagy, paginated_transactions = pagy(
|
||||
pagy_query.includes(
|
||||
{ entry: :account },
|
||||
:category, :merchant, :tags,
|
||||
transfer_as_outflow: { inflow_transaction: { entry: :account } },
|
||||
transfer_as_inflow: { outflow_transaction: { entry: :account } }
|
||||
),
|
||||
page: params["page"] || 1,
|
||||
limit: default_page_size
|
||||
)
|
||||
|
||||
totals = family.income_statement.totals(transactions_scope: transactions_query)
|
||||
|
||||
normalized_transactions = paginated_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: default_page_size,
|
||||
total_pages: pagy.pages,
|
||||
total_income: totals.income_money.format,
|
||||
total_expenses: totals.expense_money.format
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def default_page_size
|
||||
self.class.default_page_size
|
||||
end
|
||||
end
|
12
app/models/assistant/provided.rb
Normal file
12
app/models/assistant/provided.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
module Assistant::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def get_model_provider(ai_model)
|
||||
registry.providers.find { |provider| provider.supports_model?(ai_model) }
|
||||
end
|
||||
|
||||
private
|
||||
def registry
|
||||
@registry ||= Provider::Registry.for_concept(:llm)
|
||||
end
|
||||
end
|
11
app/models/assistant_message.rb
Normal file
11
app/models/assistant_message.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class AssistantMessage < Message
|
||||
validates :ai_model, presence: true
|
||||
|
||||
def role
|
||||
"assistant"
|
||||
end
|
||||
|
||||
def broadcast?
|
||||
true
|
||||
end
|
||||
end
|
64
app/models/chat.rb
Normal file
64
app/models/chat.rb
Normal file
|
@ -0,0 +1,64 @@
|
|||
class Chat < ApplicationRecord
|
||||
include Debuggable
|
||||
|
||||
belongs_to :user
|
||||
|
||||
has_one :viewer, class_name: "User", foreign_key: :last_viewed_chat_id, dependent: :nullify # "Last chat user has viewed"
|
||||
has_many :messages, dependent: :destroy
|
||||
|
||||
validates :title, presence: true
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
class << self
|
||||
def start!(prompt, model:)
|
||||
create!(
|
||||
title: generate_title(prompt),
|
||||
messages: [ UserMessage.new(content: prompt, ai_model: model) ]
|
||||
)
|
||||
end
|
||||
|
||||
def generate_title(prompt)
|
||||
prompt.first(80)
|
||||
end
|
||||
end
|
||||
|
||||
def retry_last_message!
|
||||
last_message = conversation_messages.ordered.last
|
||||
|
||||
if last_message.present? && last_message.role == "user"
|
||||
update!(error: nil)
|
||||
ask_assistant_later(last_message)
|
||||
end
|
||||
end
|
||||
|
||||
def add_error(e)
|
||||
update! error: e.to_json
|
||||
broadcast_append target: "messages", partial: "chats/error", locals: { chat: self }
|
||||
end
|
||||
|
||||
def clear_error
|
||||
update! error: nil
|
||||
broadcast_remove target: "chat-error"
|
||||
end
|
||||
|
||||
def assistant
|
||||
@assistant ||= Assistant.for_chat(self)
|
||||
end
|
||||
|
||||
def ask_assistant_later(message)
|
||||
AssistantResponseJob.perform_later(message)
|
||||
end
|
||||
|
||||
def ask_assistant(message)
|
||||
assistant.respond_to(message)
|
||||
end
|
||||
|
||||
def conversation_messages
|
||||
if debug_mode?
|
||||
messages
|
||||
else
|
||||
messages.where(type: [ "UserMessage", "AssistantMessage" ])
|
||||
end
|
||||
end
|
||||
end
|
7
app/models/chat/debuggable.rb
Normal file
7
app/models/chat/debuggable.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
module Chat::Debuggable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def debug_mode?
|
||||
ENV["AI_DEBUG_MODE"] == "true"
|
||||
end
|
||||
end
|
9
app/models/developer_message.rb
Normal file
9
app/models/developer_message.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
class DeveloperMessage < Message
|
||||
def role
|
||||
"developer"
|
||||
end
|
||||
|
||||
def broadcast?
|
||||
chat.debug_mode?
|
||||
end
|
||||
end
|
|
@ -3,7 +3,8 @@ module ExchangeRate::Provided
|
|||
|
||||
class_methods do
|
||||
def provider
|
||||
Providers.synth
|
||||
registry = Provider::Registry.for_concept(:exchange_rates)
|
||||
registry.get_provider(:synth)
|
||||
end
|
||||
|
||||
def find_or_fetch_rate(from:, to:, date: Date.current, cache: true)
|
||||
|
@ -16,8 +17,13 @@ module ExchangeRate::Provided
|
|||
|
||||
return nil unless response.success? # Provider error
|
||||
|
||||
rate = response.data.rate
|
||||
rate.save! if cache
|
||||
rate = response.data
|
||||
ExchangeRate.find_or_create_by!(
|
||||
from_currency: rate.from,
|
||||
to_currency: rate.to,
|
||||
date: rate.date,
|
||||
rate: rate.rate
|
||||
) if cache
|
||||
rate
|
||||
end
|
||||
|
||||
|
@ -34,8 +40,13 @@ module ExchangeRate::Provided
|
|||
return 0
|
||||
end
|
||||
|
||||
rates_data = fetched_rates.data.rates.map do |rate|
|
||||
rate.attributes.slice("from_currency", "to_currency", "date", "rate")
|
||||
rates_data = fetched_rates.data.map do |rate|
|
||||
{
|
||||
from_currency: rate.from,
|
||||
to_currency: rate.to,
|
||||
date: rate.date,
|
||||
rate: rate.rate
|
||||
}
|
||||
end
|
||||
|
||||
ExchangeRate.upsert_all(rates_data, unique_by: %i[from_currency to_currency date])
|
||||
|
|
|
@ -74,9 +74,9 @@ class Family < ApplicationRecord
|
|||
|
||||
def get_link_token(webhooks_url:, redirect_url:, accountable_type: nil, region: :us, access_token: nil)
|
||||
provider = if region.to_sym == :eu
|
||||
Providers.plaid_eu
|
||||
Provider::Registry.get_provider(:plaid_eu)
|
||||
else
|
||||
Providers.plaid_us
|
||||
Provider::Registry.get_provider(:plaid_us)
|
||||
end
|
||||
|
||||
# early return when no provider
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
class FinancialAssistant
|
||||
include Provided
|
||||
|
||||
def initialize(chat)
|
||||
@chat = chat
|
||||
end
|
||||
|
||||
def query(prompt, model_key: "gpt-4o")
|
||||
llm_provider = self.class.llm_provider_for(model_key)
|
||||
end
|
||||
end
|
|
@ -1,13 +0,0 @@
|
|||
module FinancialAssistant::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Placeholder for AI chat PR
|
||||
def llm_provider_for(model_key)
|
||||
case model_key
|
||||
when "gpt-4o"
|
||||
Providers.openai
|
||||
else
|
||||
raise "Unknown LLM model key: #{model_key}"
|
||||
end
|
||||
end
|
||||
end
|
22
app/models/message.rb
Normal file
22
app/models/message.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
class Message < ApplicationRecord
|
||||
belongs_to :chat
|
||||
has_many :tool_calls, dependent: :destroy
|
||||
|
||||
enum :status, {
|
||||
pending: "pending",
|
||||
complete: "complete",
|
||||
failed: "failed"
|
||||
}
|
||||
|
||||
validates :content, presence: true, allow_blank: true
|
||||
|
||||
after_create_commit -> { broadcast_append_to chat, target: "messages" }, if: :broadcast?
|
||||
after_update_commit -> { broadcast_update_to chat }, if: :broadcast?
|
||||
|
||||
scope :ordered, -> { order(created_at: :asc) }
|
||||
|
||||
private
|
||||
def broadcast?
|
||||
raise NotImplementedError, "subclasses must set #broadcast?"
|
||||
end
|
||||
end
|
|
@ -156,8 +156,8 @@ class Period
|
|||
def must_be_valid_date_range
|
||||
return if start_date.nil? || end_date.nil?
|
||||
unless start_date.is_a?(Date) && end_date.is_a?(Date)
|
||||
errors.add(:start_date, "must be a valid date")
|
||||
errors.add(:end_date, "must be a valid date")
|
||||
errors.add(:start_date, "must be a valid date, got #{start_date.inspect}")
|
||||
errors.add(:end_date, "must be a valid date, got #{end_date.inspect}")
|
||||
return
|
||||
end
|
||||
|
||||
|
|
|
@ -3,11 +3,11 @@ module PlaidItem::Provided
|
|||
|
||||
class_methods do
|
||||
def plaid_us_provider
|
||||
Providers.plaid_us
|
||||
Provider::Registry.get_provider(:plaid_us)
|
||||
end
|
||||
|
||||
def plaid_eu_provider
|
||||
Providers.plaid_eu
|
||||
Provider::Registry.get_provider(:plaid_eu)
|
||||
end
|
||||
|
||||
def plaid_provider_for_region(region)
|
||||
|
|
|
@ -1,8 +1,25 @@
|
|||
class Provider
|
||||
include Retryable
|
||||
|
||||
ProviderError = Class.new(StandardError)
|
||||
ProviderResponse = Data.define(:success?, :data, :error)
|
||||
Response = Data.define(:success?, :data, :error)
|
||||
|
||||
class Error < StandardError
|
||||
attr_reader :details, :provider
|
||||
|
||||
def initialize(message, details: nil, provider: nil)
|
||||
super(message)
|
||||
@details = details
|
||||
@provider = provider
|
||||
end
|
||||
|
||||
def as_json
|
||||
{
|
||||
provider: provider,
|
||||
message: message,
|
||||
details: details
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
PaginatedData = Data.define(:paginated, :first_page, :total_pages)
|
||||
|
@ -13,23 +30,49 @@ class Provider
|
|||
[]
|
||||
end
|
||||
|
||||
def provider_response(retries: nil, &block)
|
||||
data = if retries
|
||||
def with_provider_response(retries: default_retries, error_transformer: nil, &block)
|
||||
data = if retries > 0
|
||||
retrying(retryable_errors, max_retries: retries) { yield }
|
||||
else
|
||||
yield
|
||||
end
|
||||
|
||||
ProviderResponse.new(
|
||||
Response.new(
|
||||
success?: true,
|
||||
data: data,
|
||||
error: nil,
|
||||
)
|
||||
rescue StandardError => error
|
||||
ProviderResponse.new(
|
||||
rescue => error
|
||||
transformed_error = if error_transformer
|
||||
error_transformer.call(error)
|
||||
else
|
||||
default_error_transformer(error)
|
||||
end
|
||||
|
||||
Sentry.capture_exception(transformed_error)
|
||||
|
||||
Response.new(
|
||||
success?: false,
|
||||
data: nil,
|
||||
error: error,
|
||||
error: transformed_error
|
||||
)
|
||||
end
|
||||
|
||||
# Override to set class-level error transformation for methods using `with_provider_response`
|
||||
def default_error_transformer(error)
|
||||
if error.is_a?(Faraday::Error)
|
||||
Error.new(
|
||||
error.message,
|
||||
details: error.response&.dig(:body),
|
||||
provider: self.class.name
|
||||
)
|
||||
else
|
||||
Error.new(error.message, provider: self.class.name)
|
||||
end
|
||||
end
|
||||
|
||||
# Override to set class-level number of retries for methods using `with_provider_response`
|
||||
def default_retries
|
||||
0
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
# Defines the interface an exchange rate provider must implement
|
||||
module ExchangeRate::Provideable
|
||||
module Provider::ExchangeRateProvider
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
FetchRateData = Data.define(:rate)
|
||||
FetchRatesData = Data.define(:rates)
|
||||
|
||||
def fetch_exchange_rate(from:, to:, date:)
|
||||
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rate"
|
||||
end
|
||||
|
@ -12,4 +8,7 @@ module ExchangeRate::Provideable
|
|||
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
|
||||
raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates"
|
||||
end
|
||||
|
||||
private
|
||||
Rate = Data.define(:date, :from, :to, :rate)
|
||||
end
|
13
app/models/provider/llm_provider.rb
Normal file
13
app/models/provider/llm_provider.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
module Provider::LlmProvider
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def chat_response(message, instructions: nil, available_functions: [], streamer: nil)
|
||||
raise NotImplementedError, "Subclasses must implement #chat_response"
|
||||
end
|
||||
|
||||
private
|
||||
StreamChunk = Data.define(:type, :data)
|
||||
ChatResponse = Data.define(:id, :messages, :functions, :model)
|
||||
Message = Data.define(:id, :content)
|
||||
FunctionExecution = Data.define(:id, :call_id, :name, :arguments, :result)
|
||||
end
|
30
app/models/provider/openai.rb
Normal file
30
app/models/provider/openai.rb
Normal file
|
@ -0,0 +1,30 @@
|
|||
class Provider::Openai < Provider
|
||||
include LlmProvider
|
||||
|
||||
MODELS = %w[gpt-4o]
|
||||
|
||||
def initialize(access_token)
|
||||
@client = ::OpenAI::Client.new(access_token: access_token)
|
||||
end
|
||||
|
||||
def supports_model?(model)
|
||||
MODELS.include?(model)
|
||||
end
|
||||
|
||||
def chat_response(message, instructions: nil, available_functions: [], streamer: nil)
|
||||
with_provider_response do
|
||||
processor = ChatResponseProcessor.new(
|
||||
client: client,
|
||||
message: message,
|
||||
instructions: instructions,
|
||||
available_functions: available_functions,
|
||||
streamer: streamer
|
||||
)
|
||||
|
||||
processor.process
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :client
|
||||
end
|
188
app/models/provider/openai/chat_response_processor.rb
Normal file
188
app/models/provider/openai/chat_response_processor.rb
Normal file
|
@ -0,0 +1,188 @@
|
|||
class Provider::Openai::ChatResponseProcessor
|
||||
def initialize(message:, client:, instructions: nil, available_functions: [], streamer: nil)
|
||||
@client = client
|
||||
@message = message
|
||||
@instructions = instructions
|
||||
@available_functions = available_functions
|
||||
@streamer = streamer
|
||||
end
|
||||
|
||||
def process
|
||||
first_response = fetch_response(previous_response_id: previous_openai_response_id)
|
||||
|
||||
if first_response.functions.empty?
|
||||
if streamer.present?
|
||||
streamer.call(Provider::LlmProvider::StreamChunk.new(type: "response", data: first_response))
|
||||
end
|
||||
|
||||
return first_response
|
||||
end
|
||||
|
||||
executed_functions = execute_pending_functions(first_response.functions)
|
||||
|
||||
follow_up_response = fetch_response(
|
||||
executed_functions: executed_functions,
|
||||
previous_response_id: first_response.id
|
||||
)
|
||||
|
||||
if streamer.present?
|
||||
streamer.call(Provider::LlmProvider::StreamChunk.new(type: "response", data: follow_up_response))
|
||||
end
|
||||
|
||||
follow_up_response
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :client, :message, :instructions, :available_functions, :streamer
|
||||
|
||||
PendingFunction = Data.define(:id, :call_id, :name, :arguments)
|
||||
|
||||
def fetch_response(executed_functions: [], previous_response_id: nil)
|
||||
function_results = executed_functions.map do |executed_function|
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: executed_function.call_id,
|
||||
output: executed_function.result.to_json
|
||||
}
|
||||
end
|
||||
|
||||
prepared_input = input + function_results
|
||||
|
||||
# No need to pass tools for follow-up messages that provide function results
|
||||
prepared_tools = executed_functions.empty? ? tools : []
|
||||
|
||||
raw_response = nil
|
||||
|
||||
internal_streamer = proc do |chunk|
|
||||
type = chunk.dig("type")
|
||||
|
||||
if streamer.present?
|
||||
case type
|
||||
when "response.output_text.delta", "response.refusal.delta"
|
||||
# We don't distinguish between text and refusal yet, so stream both the same
|
||||
streamer.call(Provider::LlmProvider::StreamChunk.new(type: "output_text", data: chunk.dig("delta")))
|
||||
when "response.function_call_arguments.done"
|
||||
streamer.call(Provider::LlmProvider::StreamChunk.new(type: "function_request", data: chunk.dig("arguments")))
|
||||
end
|
||||
end
|
||||
|
||||
if type == "response.completed"
|
||||
raw_response = chunk.dig("response")
|
||||
end
|
||||
end
|
||||
|
||||
client.responses.create(parameters: {
|
||||
model: model,
|
||||
input: prepared_input,
|
||||
instructions: instructions,
|
||||
tools: prepared_tools,
|
||||
previous_response_id: previous_response_id,
|
||||
stream: internal_streamer
|
||||
})
|
||||
|
||||
if raw_response.dig("status") == "failed" || raw_response.dig("status") == "incomplete"
|
||||
raise Provider::Openai::Error.new("OpenAI returned a failed or incomplete response", { chunk: chunk })
|
||||
end
|
||||
|
||||
response_output = raw_response.dig("output")
|
||||
|
||||
functions_output = if executed_functions.any?
|
||||
executed_functions
|
||||
else
|
||||
extract_pending_functions(response_output)
|
||||
end
|
||||
|
||||
Provider::LlmProvider::ChatResponse.new(
|
||||
id: raw_response.dig("id"),
|
||||
messages: extract_messages(response_output),
|
||||
functions: functions_output,
|
||||
model: raw_response.dig("model")
|
||||
)
|
||||
end
|
||||
|
||||
def chat
|
||||
message.chat
|
||||
end
|
||||
|
||||
def model
|
||||
message.ai_model
|
||||
end
|
||||
|
||||
def previous_openai_response_id
|
||||
chat.latest_assistant_response_id
|
||||
end
|
||||
|
||||
# Since we're using OpenAI's conversation state management, all we need to pass
|
||||
# to input is the user message we're currently responding to.
|
||||
def input
|
||||
[ { role: "user", content: message.content } ]
|
||||
end
|
||||
|
||||
def extract_messages(response_output)
|
||||
message_items = response_output.filter { |item| item.dig("type") == "message" }
|
||||
|
||||
message_items.map do |item|
|
||||
output_text = item.dig("content").map do |content|
|
||||
text = content.dig("text")
|
||||
refusal = content.dig("refusal")
|
||||
|
||||
text || refusal
|
||||
end.flatten.join("\n")
|
||||
|
||||
Provider::LlmProvider::Message.new(
|
||||
id: item.dig("id"),
|
||||
content: output_text,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def extract_pending_functions(response_output)
|
||||
response_output.filter { |item| item.dig("type") == "function_call" }.map do |item|
|
||||
PendingFunction.new(
|
||||
id: item.dig("id"),
|
||||
call_id: item.dig("call_id"),
|
||||
name: item.dig("name"),
|
||||
arguments: item.dig("arguments"),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_pending_functions(pending_functions)
|
||||
pending_functions.map do |pending_function|
|
||||
execute_function(pending_function)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_function(fn)
|
||||
fn_instance = available_functions.find { |f| f.name == fn.name }
|
||||
parsed_args = JSON.parse(fn.arguments)
|
||||
result = fn_instance.call(parsed_args)
|
||||
|
||||
Provider::LlmProvider::FunctionExecution.new(
|
||||
id: fn.id,
|
||||
call_id: fn.call_id,
|
||||
name: fn.name,
|
||||
arguments: parsed_args,
|
||||
result: result
|
||||
)
|
||||
rescue => e
|
||||
fn_execution_details = {
|
||||
fn_name: fn.name,
|
||||
fn_args: parsed_args
|
||||
}
|
||||
|
||||
raise Provider::Openai::Error.new(e, fn_execution_details)
|
||||
end
|
||||
|
||||
def tools
|
||||
available_functions.map do |fn|
|
||||
{
|
||||
type: "function",
|
||||
name: fn.name,
|
||||
description: fn.description,
|
||||
parameters: fn.params_schema,
|
||||
strict: fn.strict_mode?
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
13
app/models/provider/openai/chat_streamer.rb
Normal file
13
app/models/provider/openai/chat_streamer.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
# A stream proxy for OpenAI chat responses
|
||||
#
|
||||
# - Consumes an OpenAI chat response stream
|
||||
# - Outputs a generic "Chat Provider Stream" interface to consumers (e.g. `Assistant`)
|
||||
class Provider::Openai::ChatStreamer
|
||||
def initialize(output_stream)
|
||||
@output_stream = output_stream
|
||||
end
|
||||
|
||||
def call(chunk)
|
||||
@output_stream.call(chunk)
|
||||
end
|
||||
end
|
91
app/models/provider/registry.rb
Normal file
91
app/models/provider/registry.rb
Normal file
|
@ -0,0 +1,91 @@
|
|||
class Provider::Registry
|
||||
include ActiveModel::Validations
|
||||
|
||||
Error = Class.new(StandardError)
|
||||
|
||||
CONCEPTS = %i[exchange_rates securities llm]
|
||||
|
||||
validates :concept, inclusion: { in: CONCEPTS }
|
||||
|
||||
class << self
|
||||
def for_concept(concept)
|
||||
new(concept.to_sym)
|
||||
end
|
||||
|
||||
def get_provider(name)
|
||||
send(name)
|
||||
rescue NoMethodError
|
||||
raise Error.new("Provider '#{name}' not found in registry")
|
||||
end
|
||||
|
||||
private
|
||||
def synth
|
||||
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
|
||||
|
||||
return nil unless api_key.present?
|
||||
|
||||
Provider::Synth.new(api_key)
|
||||
end
|
||||
|
||||
def plaid_us
|
||||
config = Rails.application.config.plaid
|
||||
|
||||
return nil unless config.present?
|
||||
|
||||
Provider::Plaid.new(config, region: :us)
|
||||
end
|
||||
|
||||
def plaid_eu
|
||||
config = Rails.application.config.plaid_eu
|
||||
|
||||
return nil unless config.present?
|
||||
|
||||
Provider::Plaid.new(config, region: :eu)
|
||||
end
|
||||
|
||||
def github
|
||||
Provider::Github.new
|
||||
end
|
||||
|
||||
def openai
|
||||
access_token = ENV.fetch("OPENAI_ACCESS_TOKEN", Setting.openai_access_token)
|
||||
|
||||
return nil unless access_token.present?
|
||||
|
||||
Provider::Openai.new(access_token)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(concept)
|
||||
@concept = concept
|
||||
validate!
|
||||
end
|
||||
|
||||
def providers
|
||||
available_providers.map { |p| self.class.send(p) }
|
||||
end
|
||||
|
||||
def get_provider(name)
|
||||
provider_method = available_providers.find { |p| p == name.to_sym }
|
||||
|
||||
raise Error.new("Provider '#{name}' not found for concept: #{concept}") unless provider_method.present?
|
||||
|
||||
self.class.send(provider_method)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :concept
|
||||
|
||||
def available_providers
|
||||
case concept
|
||||
when :exchange_rates
|
||||
%i[synth]
|
||||
when :securities
|
||||
%i[synth]
|
||||
when :llm
|
||||
%i[openai]
|
||||
else
|
||||
%i[synth plaid_us plaid_eu github openai]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,18 +1,6 @@
|
|||
module Security::Provideable
|
||||
module Provider::SecurityProvider
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
Search = Data.define(:securities)
|
||||
PriceData = Data.define(:price)
|
||||
PricesData = Data.define(:prices)
|
||||
SecurityInfo = Data.define(
|
||||
:ticker,
|
||||
:name,
|
||||
:links,
|
||||
:logo_url,
|
||||
:description,
|
||||
:kind,
|
||||
)
|
||||
|
||||
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
|
||||
raise NotImplementedError, "Subclasses must implement #search_securities"
|
||||
end
|
||||
|
@ -28,4 +16,9 @@ module Security::Provideable
|
|||
def fetch_security_prices(security, start_date:, end_date:)
|
||||
raise NotImplementedError, "Subclasses must implement #fetch_security_prices"
|
||||
end
|
||||
|
||||
private
|
||||
Security = Data.define(:symbol, :name, :logo_url, :exchange_operating_mic)
|
||||
SecurityInfo = Data.define(:symbol, :name, :links, :logo_url, :description, :kind)
|
||||
Price = Data.define(:security, :date, :price, :currency)
|
||||
end
|
|
@ -1,20 +1,19 @@
|
|||
class Provider::Synth < Provider
|
||||
include ExchangeRate::Provideable
|
||||
include Security::Provideable
|
||||
include ExchangeRateProvider, SecurityProvider
|
||||
|
||||
def initialize(api_key)
|
||||
@api_key = api_key
|
||||
end
|
||||
|
||||
def healthy?
|
||||
provider_response do
|
||||
with_provider_response do
|
||||
response = client.get("#{base_url}/user")
|
||||
JSON.parse(response.body).dig("id").present?
|
||||
end
|
||||
end
|
||||
|
||||
def usage
|
||||
provider_response do
|
||||
with_provider_response do
|
||||
response = client.get("#{base_url}/user")
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
|
@ -37,7 +36,7 @@ class Provider::Synth < Provider
|
|||
# ================================
|
||||
|
||||
def fetch_exchange_rate(from:, to:, date:)
|
||||
provider_response retries: 2 do
|
||||
with_provider_response retries: 2 do
|
||||
response = client.get("#{base_url}/rates/historical") do |req|
|
||||
req.params["date"] = date.to_s
|
||||
req.params["from"] = from
|
||||
|
@ -46,19 +45,12 @@ class Provider::Synth < Provider
|
|||
|
||||
rates = JSON.parse(response.body).dig("data", "rates")
|
||||
|
||||
ExchangeRate::Provideable::FetchRateData.new(
|
||||
rate: ExchangeRate.new(
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
date: date,
|
||||
rate: rates.dig(to)
|
||||
)
|
||||
)
|
||||
Rate.new(date:, from:, to:, rate: rates.dig(to))
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
|
||||
provider_response retries: 1 do
|
||||
with_provider_response retries: 1 do
|
||||
data = paginate(
|
||||
"#{base_url}/rates/historical-range",
|
||||
from: from,
|
||||
|
@ -69,16 +61,9 @@ class Provider::Synth < Provider
|
|||
body.dig("data")
|
||||
end
|
||||
|
||||
ExchangeRate::Provideable::FetchRatesData.new(
|
||||
rates: data.paginated.map do |exchange_rate|
|
||||
ExchangeRate.new(
|
||||
from_currency: from,
|
||||
to_currency: to,
|
||||
date: exchange_rate.dig("date"),
|
||||
rate: exchange_rate.dig("rates", to)
|
||||
)
|
||||
end
|
||||
)
|
||||
data.paginated.map do |rate|
|
||||
Rate.new(date: rate.dig("date"), from:, to:, rate: rate.dig("rates", to))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -87,7 +72,7 @@ class Provider::Synth < Provider
|
|||
# ================================
|
||||
|
||||
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
|
||||
provider_response do
|
||||
with_provider_response do
|
||||
response = client.get("#{base_url}/tickers/search") do |req|
|
||||
req.params["name"] = symbol
|
||||
req.params["dataset"] = "limited"
|
||||
|
@ -98,24 +83,19 @@ class Provider::Synth < Provider
|
|||
|
||||
parsed = JSON.parse(response.body)
|
||||
|
||||
Security::Provideable::Search.new(
|
||||
securities: parsed.dig("data").map do |security|
|
||||
Security.new(
|
||||
ticker: security.dig("symbol"),
|
||||
name: security.dig("name"),
|
||||
logo_url: security.dig("logo_url"),
|
||||
exchange_acronym: security.dig("exchange", "acronym"),
|
||||
exchange_mic: security.dig("exchange", "mic_code"),
|
||||
exchange_operating_mic: security.dig("exchange", "operating_mic_code"),
|
||||
country_code: security.dig("exchange", "country_code")
|
||||
)
|
||||
end
|
||||
)
|
||||
parsed.dig("data").map do |security|
|
||||
Security.new(
|
||||
symbol: security.dig("symbol"),
|
||||
name: security.dig("name"),
|
||||
logo_url: security.dig("logo_url"),
|
||||
exchange_operating_mic: security.dig("exchange", "operating_mic_code"),
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_security_info(security)
|
||||
provider_response do
|
||||
with_provider_response do
|
||||
response = client.get("#{base_url}/tickers/#{security.ticker}") do |req|
|
||||
req.params["mic_code"] = security.exchange_mic if security.exchange_mic.present?
|
||||
req.params["operating_mic"] = security.exchange_operating_mic if security.exchange_operating_mic.present?
|
||||
|
@ -123,8 +103,8 @@ class Provider::Synth < Provider
|
|||
|
||||
data = JSON.parse(response.body).dig("data")
|
||||
|
||||
Security::Provideable::SecurityInfo.new(
|
||||
ticker: security.ticker,
|
||||
SecurityInfo.new(
|
||||
symbol: data.dig("ticker"),
|
||||
name: data.dig("name"),
|
||||
links: data.dig("links"),
|
||||
logo_url: data.dig("logo_url"),
|
||||
|
@ -135,19 +115,17 @@ class Provider::Synth < Provider
|
|||
end
|
||||
|
||||
def fetch_security_price(security, date:)
|
||||
provider_response do
|
||||
with_provider_response do
|
||||
historical_data = fetch_security_prices(security, start_date: date, end_date: date)
|
||||
|
||||
raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.prices.empty?
|
||||
raise ProviderError, "No prices found for security #{security.ticker} on date #{date}" if historical_data.data.empty?
|
||||
|
||||
Security::Provideable::PriceData.new(
|
||||
price: historical_data.data.prices.first
|
||||
)
|
||||
historical_data.data.first
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_security_prices(security, start_date:, end_date:)
|
||||
provider_response retries: 1 do
|
||||
with_provider_response retries: 1 do
|
||||
params = {
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
|
@ -167,16 +145,14 @@ class Provider::Synth < Provider
|
|||
exchange_mic = data.first_page.dig("exchange", "mic_code")
|
||||
exchange_operating_mic = data.first_page.dig("exchange", "operating_mic_code")
|
||||
|
||||
Security::Provideable::PricesData.new(
|
||||
prices: data.paginated.map do |price|
|
||||
Security::Price.new(
|
||||
security: security,
|
||||
date: price.dig("date"),
|
||||
price: price.dig("close") || price.dig("open"),
|
||||
currency: currency
|
||||
)
|
||||
end
|
||||
)
|
||||
data.paginated.map do |price|
|
||||
Price.new(
|
||||
security: security,
|
||||
date: price.dig("date"),
|
||||
price: price.dig("close") || price.dig("open"),
|
||||
currency: currency
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -185,7 +161,7 @@ class Provider::Synth < Provider
|
|||
# ================================
|
||||
|
||||
def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil)
|
||||
provider_response do
|
||||
with_provider_response do
|
||||
params = {
|
||||
description: description,
|
||||
amount: amount,
|
||||
|
@ -216,9 +192,7 @@ class Provider::Synth < Provider
|
|||
[
|
||||
Faraday::TimeoutError,
|
||||
Faraday::ConnectionFailed,
|
||||
Faraday::SSLError,
|
||||
Faraday::ClientError,
|
||||
Faraday::ServerError
|
||||
Faraday::SSLError
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
module Providers
|
||||
module_function
|
||||
|
||||
def synth
|
||||
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
|
||||
|
||||
return nil unless api_key.present?
|
||||
|
||||
Provider::Synth.new(api_key)
|
||||
end
|
||||
|
||||
def plaid_us
|
||||
config = Rails.application.config.plaid
|
||||
|
||||
return nil unless config.present?
|
||||
|
||||
Provider::Plaid.new(config, region: :us)
|
||||
end
|
||||
|
||||
def plaid_eu
|
||||
config = Rails.application.config.plaid_eu
|
||||
|
||||
return nil unless config.present?
|
||||
|
||||
Provider::Plaid.new(config, region: :eu)
|
||||
end
|
||||
|
||||
def github
|
||||
Provider::Github.new
|
||||
end
|
||||
|
||||
def openai
|
||||
# TODO: Placeholder for AI chat PR
|
||||
end
|
||||
end
|
|
@ -3,7 +3,8 @@ module Security::Provided
|
|||
|
||||
class_methods do
|
||||
def provider
|
||||
Providers.synth
|
||||
registry = Provider::Registry.for_concept(:securities)
|
||||
registry.get_provider(:synth)
|
||||
end
|
||||
|
||||
def search_provider(symbol, country_code: nil, exchange_operating_mic: nil)
|
||||
|
@ -12,7 +13,7 @@ module Security::Provided
|
|||
response = provider.search_securities(symbol, country_code: country_code, exchange_operating_mic: exchange_operating_mic)
|
||||
|
||||
if response.success?
|
||||
response.data.securities
|
||||
response.data
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
@ -37,11 +38,24 @@ module Security::Provided
|
|||
return 0
|
||||
end
|
||||
|
||||
fetched_prices = response.data.prices.map do |price|
|
||||
price.attributes.slice("security_id", "date", "price", "currency")
|
||||
fetched_prices = response.data.map do |price|
|
||||
{
|
||||
security_id: price.security.id,
|
||||
date: price.date,
|
||||
price: price.price,
|
||||
currency: price.currency
|
||||
}
|
||||
end
|
||||
|
||||
Security::Price.upsert_all(fetched_prices, unique_by: %i[security_id date currency])
|
||||
valid_prices = fetched_prices.reject do |price|
|
||||
is_invalid = price[:date].nil? || price[:price].nil? || price[:currency].nil?
|
||||
if is_invalid
|
||||
Rails.logger.warn("Invalid price data for security_id=#{id}: Missing required fields in price record: #{price.inspect}")
|
||||
end
|
||||
is_invalid
|
||||
end
|
||||
|
||||
Security::Price.upsert_all(valid_prices, unique_by: %i[security_id date currency])
|
||||
end
|
||||
|
||||
def find_or_fetch_price(date: Date.current, cache: true)
|
||||
|
@ -53,8 +67,13 @@ module Security::Provided
|
|||
|
||||
return nil unless response.success? # Provider error
|
||||
|
||||
price = response.data.price
|
||||
price.save! if cache
|
||||
price = response.data
|
||||
Security::Price.find_or_create_by!(
|
||||
security_id: price.security.id,
|
||||
date: price.date,
|
||||
price: price.price,
|
||||
currency: price.currency
|
||||
) if cache
|
||||
price
|
||||
end
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ class Security::SynthComboboxOption
|
|||
end
|
||||
|
||||
def to_combobox_display
|
||||
display_code = exchange_acronym.presence || exchange_operating_mic
|
||||
"#{symbol} - #{name} (#{display_code})" # shown in combobox input when selected
|
||||
"#{symbol} - #{name} (#{exchange_operating_mic})" # shown in combobox input when selected
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,6 +3,8 @@ class Setting < RailsSettings::Base
|
|||
cache_prefix { "v1" }
|
||||
|
||||
field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"]
|
||||
field :openai_access_token, type: :string, default: ENV["OPENAI_ACCESS_TOKEN"]
|
||||
|
||||
field :require_invite_for_signup, type: :boolean, default: false
|
||||
field :require_email_confirmation, type: :boolean, default: ENV.fetch("REQUIRE_EMAIL_CONFIRMATION", "true") == "true"
|
||||
end
|
||||
|
|
3
app/models/tool_call.rb
Normal file
3
app/models/tool_call.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class ToolCall < ApplicationRecord
|
||||
belongs_to :message
|
||||
end
|
4
app/models/tool_call/function.rb
Normal file
4
app/models/tool_call/function.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
class ToolCall::Function < ToolCall
|
||||
validates :function_name, :function_result, presence: true
|
||||
validates :function_arguments, presence: true, allow_blank: true
|
||||
end
|
|
@ -2,7 +2,9 @@ class User < ApplicationRecord
|
|||
has_secure_password
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :last_viewed_chat, class_name: "Chat", optional: true
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :chats, dependent: :destroy
|
||||
has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
|
||||
has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
|
||||
accepts_nested_attributes_for :family, update_only: true
|
||||
|
@ -69,6 +71,26 @@ class User < ApplicationRecord
|
|||
(display_name&.first || email.first).upcase
|
||||
end
|
||||
|
||||
def initials
|
||||
if first_name.present? && last_name.present?
|
||||
"#{first_name.first}#{last_name.first}".upcase
|
||||
else
|
||||
initial
|
||||
end
|
||||
end
|
||||
|
||||
def show_ai_sidebar?
|
||||
show_ai_sidebar
|
||||
end
|
||||
|
||||
def ai_available?
|
||||
!Rails.application.config.app_mode.self_hosted? || ENV["OPENAI_ACCESS_TOKEN"].present?
|
||||
end
|
||||
|
||||
def ai_enabled?
|
||||
ai_enabled && ai_available?
|
||||
end
|
||||
|
||||
# Deactivation
|
||||
validate :can_deactivate, if: -> { active_changed? && !active }
|
||||
after_update_commit :purge_later, if: -> { saved_change_to_active?(from: true, to: false) }
|
||||
|
|
22
app/models/user_message.rb
Normal file
22
app/models/user_message.rb
Normal file
|
@ -0,0 +1,22 @@
|
|||
class UserMessage < Message
|
||||
validates :ai_model, presence: true
|
||||
|
||||
after_create_commit :request_response_later
|
||||
|
||||
def role
|
||||
"user"
|
||||
end
|
||||
|
||||
def request_response_later
|
||||
chat.ask_assistant_later(self)
|
||||
end
|
||||
|
||||
def request_response
|
||||
chat.ask_assistant(self)
|
||||
end
|
||||
|
||||
private
|
||||
def broadcast?
|
||||
true
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue