1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-04 21:15:19 +02:00

improvements(ai): Improve AI streaming UI/UX interactions + better separation of AI provider responsibilities (#2039)

* Start refactor

* Interface updates

* Rework Assistant, Provider, and tests for better domain boundaries

* Consolidate and simplify OpenAI provider and provider concepts

* Clean up assistant streaming

* Improve assistant message orchestration logic

* Clean up "thinking" UI interactions

* Remove stale class

* Regenerate VCR test responses
This commit is contained in:
Zach Gollwitzer 2025-04-01 07:21:54 -04:00 committed by GitHub
parent 6331788b33
commit 5cf758bd03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1179 additions and 624 deletions

View file

@ -0,0 +1,12 @@
module Assistant::Broadcastable
extend ActiveSupport::Concern
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
end

View file

@ -0,0 +1,85 @@
module Assistant::Configurable
extend ActiveSupport::Concern
class_methods do
def config_for(chat)
preferred_currency = Money::Currency.new(chat.user.family.currency)
preferred_date_format = chat.user.family.date_format
{
instructions: default_instructions(preferred_currency, preferred_date_format),
functions: default_functions
}
end
private
def default_functions
[
Assistant::Function::GetTransactions,
Assistant::Function::GetAccounts,
Assistant::Function::GetBalanceSheet,
Assistant::Function::GetIncomeStatement
]
end
def default_instructions(preferred_currency, preferred_date_format)
<<~PROMPT
## Your identity
You are a friendly 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
- Format dates in the user's preferred format: #{preferred_date_format}
#### 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 specific investment advice (such as "buy this stock", "sell that bond", "invest in crypto", etc.).
Instead, you should focus on educating the user about personal finance using their own data so they can make informed decisions.
- 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
end
end

View file

@ -34,6 +34,15 @@ class Assistant::Function
true
end
def to_definition
{
name: name,
description: description,
params_schema: params_schema,
strict: strict_mode?
}
end
private
attr_reader :user

View file

@ -0,0 +1,37 @@
class Assistant::FunctionToolCaller
Error = Class.new(StandardError)
FunctionExecutionError = Class.new(Error)
attr_reader :functions
def initialize(functions = [])
@functions = functions
end
def fulfill_requests(function_requests)
function_requests.map do |function_request|
result = execute(function_request)
ToolCall::Function.from_function_request(function_request, result)
end
end
def function_definitions
functions.map(&:to_definition)
end
private
def execute(function_request)
fn = find_function(function_request)
fn_args = JSON.parse(function_request.function_args)
fn.call(fn_args)
rescue => e
raise FunctionExecutionError.new(
"Error calling function #{fn.name} with arguments #{fn_args}: #{e.message}"
)
end
def find_function(function_request)
functions.find { |f| f.name == function_request.function_name }
end
end

View file

@ -0,0 +1,87 @@
class Assistant::Responder
def initialize(message:, instructions:, function_tool_caller:, llm:)
@message = message
@instructions = instructions
@function_tool_caller = function_tool_caller
@llm = llm
end
def on(event_name, &block)
listeners[event_name.to_sym] << block
end
def respond(previous_response_id: nil)
# For the first response
streamer = proc do |chunk|
case chunk.type
when "output_text"
emit(:output_text, chunk.data)
when "response"
response = chunk.data
if response.function_requests.any?
handle_follow_up_response(response)
else
emit(:response, { id: response.id })
end
end
end
get_llm_response(streamer: streamer, previous_response_id: previous_response_id)
end
private
attr_reader :message, :instructions, :function_tool_caller, :llm
def handle_follow_up_response(response)
streamer = proc do |chunk|
case chunk.type
when "output_text"
emit(:output_text, chunk.data)
when "response"
# We do not currently support function executions for a follow-up response (avoid recursive LLM calls that could lead to high spend)
emit(:response, { id: chunk.data.id })
end
end
function_tool_calls = function_tool_caller.fulfill_requests(response.function_requests)
emit(:response, {
id: response.id,
function_tool_calls: function_tool_calls
})
# Get follow-up response with tool call results
get_llm_response(
streamer: streamer,
function_results: function_tool_calls.map(&:to_result),
previous_response_id: response.id
)
end
def get_llm_response(streamer:, function_results: [], previous_response_id: nil)
response = llm.chat_response(
message.content,
model: message.ai_model,
instructions: instructions,
functions: function_tool_caller.function_definitions,
function_results: function_results,
streamer: streamer,
previous_response_id: previous_response_id
)
unless response.success?
raise response.error
end
response.data
end
def emit(event_name, payload = nil)
listeners[event_name.to_sym].each { |block| block.call(payload) }
end
def listeners
@listeners ||= Hash.new { |h, k| h[k] = [] }
end
end