Add back thinking messages, clean up error handling for chat

This commit is contained in:
Zach Gollwitzer
2025-03-25 22:13:28 -04:00
parent 4f7e4e40d8
commit b56418540c
11 changed files with 99 additions and 109 deletions

View File

@@ -21,7 +21,11 @@ class ChatsController < ApplicationController
def create
@chat = Current.user.chats.start!(chat_params[:content], model: chat_params[:ai_model])
set_last_viewed_chat(@chat)
redirect_to chat_path(@chat)
respond_to do |format|
format.html { redirect_to chat_path(@chat) }
format.turbo_stream
end
end
def edit
@@ -45,7 +49,14 @@ class ChatsController < ApplicationController
def retry
@chat.retry_last_message!
redirect_to chat_path(@chat)
respond_to do |format|
format.html { redirect_to chat_path(@chat) }
format.turbo_stream { render turbo_stream: [
turbo_stream.append("messages", partial: "chats/thinking_indicator", locals: { chat: @chat }),
turbo_stream.remove("chat-error")
] }
end
end
private

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

@@ -14,30 +14,31 @@ class Assistant
end
def respond_to(message)
sleep artificial_thinking_delay
provider = get_model_provider(message.ai_model)
response = provider.chat_response(message, instructions: instructions, available_functions: functions)
if response.success?
Chat.transaction do
process_response_artifacts(response.data)
stop_thinking
chat.update!(
error: nil,
latest_assistant_response_id: response.data.id
)
end
chat.broadcast_remove target: "chat-error"
else
chat.update!(error: "#{response.error.class}: #{response.error.message}")
chat.broadcast_append target: "messages", partial: "chats/error", locals: { chat: chat }
unless response.success?
chat.add_error("#{response.error.class}: #{response.error.message}")
end
Chat.transaction do
chat.clear_error
process_response_artifacts(response.data)
chat.update!(latest_assistant_response_id: response.data.id)
end
rescue => e
chat.add_error("#{e.class}: #{e.message}")
end
private
def chat_history
chat.messages.where.not(debug: true).ordered
def stop_thinking
sleep artificial_thinking_delay
chat.broadcast_remove target: "thinking-indicator"
end
def process_response_artifacts(data)
@@ -123,17 +124,21 @@ class Assistant
def functions
[
Assistant::Function::GetTransactions.new(chat.user)
Assistant::Function::GetTransactions.new(chat.user),
Assistant::Function::GetAccounts.new(chat.user)
]
# Assistant::Function::GetBalanceSheet.new(chat),
# Assistant::Function::GetIncomeStatement.new(chat),
# Assistant::Function::GetExpenseCategories.new(chat),
# Assistant::Function::GetAccountBalances.new(chat),
# Assistant::Function::ComparePeriods.new(chat)
end
def preferred_currency
Money::Currency.new(chat.user.family.currency)
end
def artificial_thinking_delay
1
end
end

View File

@@ -10,58 +10,33 @@ class Assistant::Function::GetAccounts < Assistant::Function
end
def call(params = {})
account_type = params["account_type"] || "all"
balance_sheet = BalanceSheet.new(family)
{
as_of_date: Date.today.to_s,
currency: family.currency,
accounts: get_accounts_data(balance_sheet, account_type)
}
end
as_of_date: Date.current,
accounts: family.accounts.includes(:balances).map do |account|
series_start_date = [ account.start_date, 5.years.ago.to_date ].max
all_dates = Period.custom(start_date: series_start_date, end_date: Date.current)
balance_series = account.balance_series(period: all_dates, interval: "1 month")
def params_schema
{
type: "object",
properties: {
account_type: {
type: "string",
enum: [ "asset", "liability", "all" ],
description: "Type of accounts to get balances for"
}
},
required: [ "account_type" ],
additionalProperties: false
}
end
private
def get_accounts_data(balance_sheet, account_type)
accounts = case account_type
when "asset"
balance_sheet.account_groups("asset")
when "liability"
balance_sheet.account_groups("liability")
else
balance_sheet.account_groups
end
accounts.flat_map { |group| format_accounts(group.accounts) }
end
def format_accounts(accounts)
accounts.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,
balance: format_currency(account.balance),
classification: account.classification
start_date: account.start_date,
is_plaid_linked: account.plaid_account_id.present?,
is_active: account.is_active,
historical_balances: {
start_date: balance_series.start_date,
end_date: balance_series.end_date,
currency: account.currency,
interval: balance_series.interval,
order: "chronological",
balances: balance_series.values.map { |value| { date: value.date, balance_formatted: value.trend.current.format } }
}
}
end
end
def format_currency(amount)
Money.new(amount, family.currency).format
end
}
end
end

View File

@@ -2,6 +2,10 @@ class Assistant::Function::GetTransactions < Assistant::Function
include Pagy::Backend
class << self
def default_page_size
50
end
def name
"get_transactions"
end
@@ -16,7 +20,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
- `total_pages`: The total number of pages of results
- `page`: The current page of results
- `page_size`: The number of results per page (this will always be 50)
- `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
Simple example (transactions from the last 30 days):
@@ -24,7 +28,6 @@ class Assistant::Function::GetTransactions < Assistant::Function
```
get_transactions({
page: 1,
page_size: 50,
start_date: "#{30.days.ago.to_date}",
end_date: "#{Date.current}"
})
@@ -35,7 +38,6 @@ class Assistant::Function::GetTransactions < Assistant::Function
```
get_transactions({
page: 1,
page_size: 50,
search: "mcdonalds",
accounts: ["Checking", "Savings"],
start_date: "#{30.days.ago.to_date}",
@@ -59,10 +61,6 @@ class Assistant::Function::GetTransactions < Assistant::Function
build_schema(
required: [ "order", "page", "page_size" ],
properties: {
page_size: {
const: 50,
description: "Number of transactions per page (always 50)"
},
page: {
type: "integer",
description: "Page number"
@@ -125,7 +123,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
end
def call(params = {})
search_params = params.except("order", "page", "page_size")
search_params = params.except("order", "page")
transactions = family.transactions.active.search(search_params).includes(
{ entry: :account },
@@ -137,7 +135,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
ordered_transactions = params["order"] == "asc" ? transactions.chronological : transactions.reverse_chronological
# By default, we give a small page size to force the AI to use filters effectively and save on tokens
pagy, transactions = pagy(ordered_transactions, page: params["page"] || 1, limit: 10)
pagy, transactions = pagy(ordered_transactions, page: params["page"] || 1, limit: default_page_size)
normalized_transactions = transactions.map do |txn|
entry = txn.entry
@@ -159,8 +157,13 @@ class Assistant::Function::GetTransactions < Assistant::Function
transactions: normalized_transactions,
total_results: pagy.count,
page: pagy.page,
page_size: 10,
page_size: default_page_size,
total_pages: pagy.pages
}
end
private
def default_page_size
self.class.default_page_size
end
end

View File

@@ -27,10 +27,21 @@ class Chat < ApplicationRecord
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(message)
update! error: message
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

View File

@@ -1,3 +1,4 @@
class ToolCall::Function < ToolCall
validates :function_name, :function_arguments, :function_result, presence: true
validates :function_name, :function_result, presence: true
validates :function_arguments, presence: true, allow_blank: true
end

View File

@@ -1,6 +1,6 @@
<%# locals: (message: "Thinking ...") -%>
<%# locals: (chat:, message: "Thinking ...") -%>
<div id="thinking-message" class="flex items-start">
<div id="thinking-indicator" class="flex items-start">
<%= render "chats/ai_avatar" %>
<p class="text-sm text-secondary animate-pulse"><%= message %></p>
</div>

View File

@@ -0,0 +1,5 @@
<%= turbo_stream.replace "chat-form" do %>
<%= render "messages/chat_form", chat: @chat %>
<% end %>
<%= turbo_stream.append "messages", partial: "chats/thinking_indicator", locals: { chat: @chat } %>

View File

@@ -1,26 +0,0 @@
<%# locals: (content:) %>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3 w-full text-gray-700 text-xs font-mono">
<div class="flex items-center mb-1">
<div class="mr-1"><%= icon("terminal", size: 14) %></div>
<div class="font-semibold"><%= content.split("\n").first %></div>
</div>
<% if content.include?("```json") %>
<%
json_start = content.index("```json")
json_end = content.index("```", json_start + 7)
if json_start && json_end
json_content = content[(json_start + 7)...json_end].strip
begin
parsed_json = JSON.parse(json_content)
formatted_json = JSON.pretty_generate(parsed_json)
rescue
formatted_json = json_content
end
end
%>
<div class="mt-2 overflow-x-auto">
<pre class="text-xs"><%= formatted_json %></pre>
</div>
<% end %>
</div>

View File

@@ -1,3 +1,5 @@
<%= turbo_stream.replace "chat-form" do %>
<%= render "messages/chat_form", chat: @chat %>
<% end %>
<%= turbo_stream.append "messages", partial: "chats/thinking_indicator", locals: { chat: @chat } %>