1
0
Fork 0
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:
Zach Gollwitzer 2025-03-28 13:08:22 -04:00 committed by GitHub
parent 8e6b81af77
commit 2f6b11c18f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
126 changed files with 3576 additions and 462 deletions

View file

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

View file

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

View 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

View 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

View 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

View 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

View 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

View 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

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

View file

@ -0,0 +1,7 @@
module Chat::Debuggable
extend ActiveSupport::Concern
def debug_mode?
ENV["AI_DEBUG_MODE"] == "true"
end
end

View file

@ -0,0 +1,9 @@
class DeveloperMessage < Message
def role
"developer"
end
def broadcast?
chat.debug_mode?
end
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,3 @@
class ToolCall < ApplicationRecord
belongs_to :message
end

View 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

View file

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

View 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