mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-10 07:55:21 +02:00
Naming, domain alignment
This commit is contained in:
parent
474260f213
commit
6dc3f30a0e
14 changed files with 671 additions and 687 deletions
|
@ -23,10 +23,7 @@ class ChatsController < ApplicationController
|
|||
|
||||
set_last_viewed_chat(@chat)
|
||||
|
||||
# TODO: Enable again
|
||||
# ProcessAiResponseJob.perform_later(@message)
|
||||
|
||||
redirect_to chat_path(@chat, thinking: true)
|
||||
redirect_to chat_path(@chat)
|
||||
end
|
||||
|
||||
def edit
|
||||
|
|
|
@ -5,9 +5,6 @@ class MessagesController < ApplicationController
|
|||
def create
|
||||
@message = @chat.messages.create!(message_params)
|
||||
|
||||
# TODO: Enable again
|
||||
# ProcessAiResponseJob.perform_later(@message)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to chat_path(@chat) }
|
||||
format.turbo_stream
|
||||
|
|
|
@ -14,9 +14,6 @@ class ProcessAiResponseJob < ApplicationJob
|
|||
chat.update(title: new_title)
|
||||
end
|
||||
|
||||
# Show initial thinking indicator - use replace instead of update to ensure it works for follow-up messages
|
||||
update_thinking_indicator(chat, "Thinking...")
|
||||
|
||||
# Processing steps with progress updates
|
||||
begin
|
||||
# Step 1: Preparing request
|
||||
|
@ -93,20 +90,9 @@ class ProcessAiResponseJob < ApplicationJob
|
|||
def update_thinking_indicator(chat, message)
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
chat,
|
||||
target: "thinking",
|
||||
html: <<~HTML
|
||||
<div id="thinking" class="flex items-start gap-3">
|
||||
#{ApplicationController.render(partial: "chats/ai_avatar")}
|
||||
<div class="bg-gray-100 rounded-lg p-4 max-w-[85%] flex items-center">
|
||||
<div class="flex gap-1">
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.4s"></div>
|
||||
</div>
|
||||
<span class="ml-2 text-gray-600">#{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
HTML
|
||||
target: "thinking-message",
|
||||
partial: "messages/thinking_message",
|
||||
locals: { message: message }
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
module Ai
|
||||
module DebugMode
|
||||
# Check if debug mode is enabled
|
||||
def self.enabled?
|
||||
ENV["AI_DEBUG_MODE"] == "true"
|
||||
end
|
||||
|
||||
# Log debug information to a chat
|
||||
def self.log_to_chat(chat, message, data = nil)
|
||||
return unless enabled?
|
||||
|
||||
# Store debug messages in the database but don't output to chat
|
||||
content = message
|
||||
if data.present?
|
||||
# Limit the size of the JSON data to prevent PostgreSQL NOTIFY payload size limit errors
|
||||
if data.is_a?(Hash) && data[:backtrace].is_a?(Array)
|
||||
# Limit backtrace to first 3 entries to reduce payload size
|
||||
data[:backtrace] = data[:backtrace].first(3)
|
||||
end
|
||||
|
||||
# Convert to JSON and check size
|
||||
json_data = JSON.pretty_generate(data)
|
||||
|
||||
# If still too large, truncate it (PostgreSQL NOTIFY has ~8000 byte limit)
|
||||
if json_data.bytesize > 7000
|
||||
json_data = json_data[0...7000] + "\n... (truncated due to size limits)"
|
||||
end
|
||||
|
||||
content += "\n\n```json\n#{json_data}\n```"
|
||||
end
|
||||
|
||||
chat.messages.create!(
|
||||
role: "developer",
|
||||
content: content,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,599 +0,0 @@
|
|||
module Ai
|
||||
class FinancialAssistant
|
||||
attr_reader :family, :client
|
||||
|
||||
def initialize(family, client: nil)
|
||||
@family = family
|
||||
@client = client || OpenAI::Client.new(access_token: ENV["OPENAI_ACCESS_TOKEN"])
|
||||
end
|
||||
|
||||
def query(question, chat_history = nil)
|
||||
# Log the system prompt in debug mode
|
||||
if Ai::DebugMode.enabled? && @chat
|
||||
Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: System prompt", { prompt: system_prompt })
|
||||
end
|
||||
|
||||
# Build messages array with chat history if provided
|
||||
messages = [ { role: "system", content: system_prompt } ]
|
||||
|
||||
if chat_history.present?
|
||||
# Add previous messages from chat history, excluding system messages
|
||||
messages.concat(
|
||||
chat_history
|
||||
.conversation
|
||||
.ordered
|
||||
.map { |msg| { role: msg.role, content: msg.content } }
|
||||
)
|
||||
|
||||
# If the last message is not the current question, add it
|
||||
if messages.last[:content] != question
|
||||
messages << { role: "user", content: question }
|
||||
end
|
||||
else
|
||||
# Just add the current question if no history
|
||||
messages << { role: "user", content: question }
|
||||
end
|
||||
|
||||
# Log the messages being sent in debug mode
|
||||
if Ai::DebugMode.enabled? && @chat
|
||||
Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Messages being sent to AI", { messages: messages })
|
||||
end
|
||||
|
||||
response = client.chat(
|
||||
parameters: {
|
||||
model: "gpt-4o",
|
||||
messages: messages,
|
||||
tools: financial_function_definitions.map { |func| { type: "function", function: func } },
|
||||
tool_choice: "auto",
|
||||
temperature: 0.5
|
||||
}
|
||||
)
|
||||
|
||||
process_response(response, question, messages)
|
||||
end
|
||||
|
||||
# Set the chat for debug logging
|
||||
def with_chat(chat)
|
||||
@chat = chat
|
||||
self
|
||||
end
|
||||
|
||||
# Define the functions that can be called by GPT
|
||||
def financial_function_definitions
|
||||
[
|
||||
{
|
||||
name: "get_balance_sheet",
|
||||
description: "Get current balance sheet information including net worth, assets, and liabilities",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_income_statement",
|
||||
description: "Get income statement data for a specific time period",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
period: {
|
||||
type: "string",
|
||||
enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ],
|
||||
description: "The time period for the income statement data"
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_expense_categories",
|
||||
description: "Get top expense categories for a specific time period",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
period: {
|
||||
type: "string",
|
||||
enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ],
|
||||
description: "The time period for the expense categories data"
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: "Number of top categories to return",
|
||||
default: 5
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_account_balances",
|
||||
description: "Get balances for all accounts or by account type",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
account_type: {
|
||||
type: "string",
|
||||
enum: [ "asset", "liability", "all" ],
|
||||
description: "Type of accounts to get balances for"
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_transactions",
|
||||
description: "Get transactions filtered by date range and/or category",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
start_date: {
|
||||
type: "string",
|
||||
format: "date",
|
||||
description: "Start date for transactions (YYYY-MM-DD)"
|
||||
},
|
||||
end_date: {
|
||||
type: "string",
|
||||
format: "date",
|
||||
description: "End date for transactions (YYYY-MM-DD)"
|
||||
},
|
||||
category_name: {
|
||||
type: "string",
|
||||
description: "Filter transactions by category name"
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: "Maximum number of transactions to return",
|
||||
default: 10
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "compare_periods",
|
||||
description: "Compare financial data between two periods",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
period1: {
|
||||
type: "string",
|
||||
enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ],
|
||||
description: "First period for comparison"
|
||||
},
|
||||
period2: {
|
||||
type: "string",
|
||||
enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ],
|
||||
description: "Second period for comparison"
|
||||
}
|
||||
},
|
||||
required: [ "period1", "period2" ]
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_response(response, original_question, messages)
|
||||
message = response.dig("choices", 0, "message")
|
||||
|
||||
# Log the raw response in debug mode
|
||||
if Ai::DebugMode.enabled? && @chat
|
||||
Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Raw AI response", {
|
||||
response_type: message["tool_calls"] ? "function_call" : "direct_content",
|
||||
content: message["content"]
|
||||
})
|
||||
end
|
||||
|
||||
# If there are no function calls, ensure the direct response is concise
|
||||
return message["content"] unless message["tool_calls"]
|
||||
|
||||
# Handle function calls
|
||||
function_calls = message["tool_calls"]
|
||||
|
||||
# Log the function calls in debug mode
|
||||
if Ai::DebugMode.enabled? && @chat
|
||||
debug_function_calls = function_calls.map do |call|
|
||||
{
|
||||
function_name: call["function"]["name"],
|
||||
arguments: JSON.parse(call["function"]["arguments"])
|
||||
}
|
||||
end
|
||||
|
||||
Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Function calls", { function_calls: debug_function_calls })
|
||||
end
|
||||
|
||||
function_results = execute_function_calls(function_calls)
|
||||
|
||||
# Log the function results in debug mode
|
||||
if Ai::DebugMode.enabled? && @chat
|
||||
debug_results = function_calls.map.with_index do |call, i|
|
||||
{
|
||||
function_name: call["function"]["name"],
|
||||
result: function_results[i]
|
||||
}
|
||||
end
|
||||
|
||||
Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Function results", { results: debug_results })
|
||||
end
|
||||
|
||||
# Continue the conversation with function results
|
||||
follow_up_messages = messages.dup
|
||||
|
||||
# Add the assistant's response with function calls
|
||||
follow_up_messages << message
|
||||
|
||||
# Add the function results
|
||||
function_results.each_with_index do |result, index|
|
||||
follow_up_messages << {
|
||||
role: "tool",
|
||||
tool_call_id: function_calls[index]["id"],
|
||||
name: function_calls[index]["function"]["name"],
|
||||
content: result.to_json
|
||||
}
|
||||
end
|
||||
|
||||
# Add a reminder to be concise
|
||||
follow_up_messages << {
|
||||
role: "system",
|
||||
content: "CRITICAL: Eliminate all unnecessary words."
|
||||
}
|
||||
|
||||
# Log the follow-up request in debug mode
|
||||
if Ai::DebugMode.enabled? && @chat
|
||||
Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Follow-up request", { messages: follow_up_messages })
|
||||
end
|
||||
|
||||
follow_up_response = client.chat(
|
||||
parameters: {
|
||||
model: "gpt-4o",
|
||||
messages: follow_up_messages,
|
||||
temperature: 0.5
|
||||
}
|
||||
)
|
||||
|
||||
# Log the final response in debug mode
|
||||
final_content = follow_up_response.dig("choices", 0, "message", "content")
|
||||
if Ai::DebugMode.enabled? && @chat
|
||||
Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Final response", { content: final_content })
|
||||
end
|
||||
|
||||
# Return the final response
|
||||
final_content
|
||||
end
|
||||
|
||||
def execute_function_calls(function_calls)
|
||||
function_calls.map do |call|
|
||||
function_name = call["function"]["name"]
|
||||
arguments = JSON.parse(call["function"]["arguments"])
|
||||
|
||||
# Log the function execution in debug mode
|
||||
if Ai::DebugMode.enabled? && @chat
|
||||
Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Executing function", {
|
||||
function: function_name,
|
||||
arguments: arguments
|
||||
})
|
||||
end
|
||||
|
||||
result = case function_name
|
||||
when "get_balance_sheet"
|
||||
execute_get_balance_sheet(arguments)
|
||||
when "get_income_statement"
|
||||
execute_get_income_statement(arguments)
|
||||
when "get_expense_categories"
|
||||
execute_get_expense_categories(arguments)
|
||||
when "get_account_balances"
|
||||
execute_get_account_balances(arguments)
|
||||
when "get_transactions"
|
||||
execute_get_transactions(arguments)
|
||||
when "compare_periods"
|
||||
execute_compare_periods(arguments)
|
||||
else
|
||||
{ error: "Unknown function: #{function_name}" }
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
# Execute the get_balance_sheet function
|
||||
def execute_get_balance_sheet(params = {})
|
||||
balance_sheet = BalanceSheet.new(family)
|
||||
balance_sheet.to_ai_readable_hash
|
||||
end
|
||||
|
||||
# Execute the get_income_statement function
|
||||
def execute_get_income_statement(params = {})
|
||||
income_statement = IncomeStatement.new(family)
|
||||
period = get_period_from_param(params["period"])
|
||||
income_statement.to_ai_readable_hash(period: period)
|
||||
end
|
||||
|
||||
# Execute the get_expense_categories function
|
||||
def execute_get_expense_categories(params = {})
|
||||
income_statement = IncomeStatement.new(family)
|
||||
period = get_period_from_param(params["period"])
|
||||
limit = params["limit"] || 5
|
||||
|
||||
expense_data = income_statement.expense_totals(period: period)
|
||||
|
||||
top_categories = expense_data.category_totals
|
||||
.reject { |ct| ct.category.subcategory? }
|
||||
.sort_by { |ct| -ct.total }
|
||||
.take(limit)
|
||||
.map do |ct|
|
||||
{
|
||||
name: ct.category.name,
|
||||
amount: format_currency(ct.total),
|
||||
percentage: ct.weight.round(2)
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
period: {
|
||||
start_date: period.start_date.to_s,
|
||||
end_date: period.end_date.to_s
|
||||
},
|
||||
total_expenses: format_currency(expense_data.total),
|
||||
top_categories: top_categories,
|
||||
currency: family.currency
|
||||
}
|
||||
end
|
||||
|
||||
# Execute the get_account_balances function
|
||||
def execute_get_account_balances(params = {})
|
||||
account_type = params["account_type"] || "all"
|
||||
balance_sheet = BalanceSheet.new(family)
|
||||
|
||||
accounts = case account_type
|
||||
when "asset"
|
||||
balance_sheet.account_groups("asset")
|
||||
when "liability"
|
||||
balance_sheet.account_groups("liability")
|
||||
else
|
||||
balance_sheet.account_groups
|
||||
end
|
||||
|
||||
account_data = accounts.flat_map do |group|
|
||||
group.accounts.map do |account|
|
||||
{
|
||||
name: account.name,
|
||||
type: account.accountable_type,
|
||||
balance: format_currency(account.balance),
|
||||
classification: account.classification
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
as_of_date: Date.today.to_s,
|
||||
currency: family.currency,
|
||||
accounts: account_data
|
||||
}
|
||||
end
|
||||
|
||||
# Execute the get_transactions function
|
||||
def execute_get_transactions(params = {})
|
||||
start_date = params["start_date"] ? Date.parse(params["start_date"]) : 30.days.ago.to_date
|
||||
end_date = params["end_date"] ? Date.parse(params["end_date"]) : Date.today
|
||||
category_name = params["category_name"]
|
||||
limit = params["limit"] || 10
|
||||
|
||||
transactions_query = family.transactions.active.in_period(Period.new(start_date: start_date, end_date: end_date))
|
||||
|
||||
if category_name.present?
|
||||
# Try to find an exact match first
|
||||
category = family.categories.find_by(name: category_name)
|
||||
|
||||
# If no exact match, try fuzzy matching
|
||||
unless category
|
||||
# Try case-insensitive contains matching
|
||||
categories = family.categories.where("LOWER(name) LIKE ?", "%#{category_name.downcase}%")
|
||||
|
||||
# If still no match, try common synonyms
|
||||
if categories.empty?
|
||||
synonyms = {
|
||||
"food" => [ "grocery", "groceries", "supermarket", "dining", "restaurant", "meal" ],
|
||||
"groceries" => [ "food", "grocery", "supermarket" ],
|
||||
"dining" => [ "restaurant", "food", "eating out", "meal" ],
|
||||
"utilities" => [ "utility", "bills", "electricity", "water", "gas" ],
|
||||
"transportation" => [ "travel", "car", "bus", "transit", "commute" ],
|
||||
"shopping" => [ "retail", "clothes", "merchandise" ]
|
||||
# Add more common synonyms as needed
|
||||
}
|
||||
|
||||
matched_categories = []
|
||||
synonyms.each do |formal_term, informal_terms|
|
||||
if category_name.downcase == formal_term.downcase ||
|
||||
informal_terms.any? { |term| category_name.downcase.include?(term.downcase) }
|
||||
matched_categories += family.categories.where("LOWER(name) LIKE ?", "%#{formal_term.downcase}%")
|
||||
end
|
||||
end
|
||||
|
||||
categories = matched_categories.uniq if matched_categories.any?
|
||||
end
|
||||
|
||||
# Use the first matching category if any were found
|
||||
category = categories.first if categories.any?
|
||||
end
|
||||
|
||||
# If we found a category through any matching method, filter by it
|
||||
if category
|
||||
transactions_query = transactions_query.where(category_id: category.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Use eager loading to avoid N+1 queries and ensure all attributes are available
|
||||
transactions_query = transactions_query.includes(:account_entry, :category, :merchant)
|
||||
|
||||
# Specify the table name explicitly to avoid ambiguous column reference
|
||||
transactions = transactions_query.order("account_entries.date DESC").limit(limit)
|
||||
|
||||
transaction_data = transactions.map do |transaction|
|
||||
# Access the date through the entry association
|
||||
entry = transaction.account_entry
|
||||
{
|
||||
date: entry.date,
|
||||
name: entry.name,
|
||||
amount: format_currency(entry.amount),
|
||||
category: transaction.category&.name || "Uncategorized",
|
||||
merchant: transaction.merchant&.name
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
period: {
|
||||
start_date: start_date.to_s,
|
||||
end_date: end_date.to_s
|
||||
},
|
||||
transactions: transaction_data,
|
||||
count: transaction_data.size,
|
||||
currency: family.currency,
|
||||
search_info: {
|
||||
category_query: category_name,
|
||||
matched_category: category&.name
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# Execute the compare_periods function
|
||||
def execute_compare_periods(params = {})
|
||||
period1 = get_period_from_param(params["period1"])
|
||||
period2 = get_period_from_param(params["period2"])
|
||||
|
||||
income_statement = IncomeStatement.new(family)
|
||||
|
||||
period1_data = {
|
||||
income: income_statement.income_totals(period: period1),
|
||||
expenses: income_statement.expense_totals(period: period1)
|
||||
}
|
||||
|
||||
period2_data = {
|
||||
income: income_statement.income_totals(period: period2),
|
||||
expenses: income_statement.expense_totals(period: period2)
|
||||
}
|
||||
|
||||
# Calculate differences
|
||||
income_diff = period1_data[:income].total - period2_data[:income].total
|
||||
expenses_diff = period1_data[:expenses].total - period2_data[:expenses].total
|
||||
net_income_diff = income_diff - expenses_diff
|
||||
|
||||
# Calculate percentage changes
|
||||
income_pct_change = period2_data[:income].total > 0 ? (income_diff / period2_data[:income].total.to_f * 100).round(2) : 0
|
||||
expenses_pct_change = period2_data[:expenses].total > 0 ? (expenses_diff / period2_data[:expenses].total.to_f * 100).round(2) : 0
|
||||
|
||||
{
|
||||
period1: {
|
||||
name: period_name(params["period1"]),
|
||||
start_date: period1.start_date.to_s,
|
||||
end_date: period1.end_date.to_s,
|
||||
total_income: format_currency(period1_data[:income].total),
|
||||
total_expenses: format_currency(period1_data[:expenses].total),
|
||||
net_income: format_currency(period1_data[:income].total - period1_data[:expenses].total)
|
||||
},
|
||||
period2: {
|
||||
name: period_name(params["period2"]),
|
||||
start_date: period2.start_date.to_s,
|
||||
end_date: period2.end_date.to_s,
|
||||
total_income: format_currency(period2_data[:income].total),
|
||||
total_expenses: format_currency(period2_data[:expenses].total),
|
||||
net_income: format_currency(period2_data[:income].total - period2_data[:expenses].total)
|
||||
},
|
||||
differences: {
|
||||
income: format_currency(income_diff),
|
||||
income_percent: income_pct_change,
|
||||
expenses: format_currency(expenses_diff),
|
||||
expenses_percent: expenses_pct_change,
|
||||
net_income: format_currency(net_income_diff)
|
||||
},
|
||||
currency: family.currency
|
||||
}
|
||||
end
|
||||
|
||||
# Helper to convert period string to a Period object
|
||||
def get_period_from_param(period_param)
|
||||
case period_param
|
||||
when "current_month"
|
||||
Period.current_month
|
||||
when "previous_month"
|
||||
Period.previous_month
|
||||
when "year_to_date"
|
||||
Period.year_to_date
|
||||
when "previous_year"
|
||||
Period.previous_year
|
||||
else
|
||||
Period.current_month
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to get human-readable period name
|
||||
def period_name(period_param)
|
||||
case period_param
|
||||
when "current_month"
|
||||
"Current Month"
|
||||
when "previous_month"
|
||||
"Previous Month"
|
||||
when "year_to_date"
|
||||
"Year to Date"
|
||||
when "previous_year"
|
||||
"Previous Year"
|
||||
else
|
||||
"Custom Period"
|
||||
end
|
||||
end
|
||||
|
||||
# Format currency values consistently for AI display
|
||||
def format_currency(amount, currency = family.currency)
|
||||
Money.new(amount, currency).format
|
||||
end
|
||||
|
||||
# System prompt for the GPT model
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are a helpful financial assistant for Maybe, a personal finance app.
|
||||
You help users understand their financial data by answering questions about their accounts, transactions, income, expenses, and net worth.
|
||||
|
||||
When users ask financial questions:
|
||||
1. Use the provided functions to retrieve the relevant data
|
||||
2. Provide ONLY the most important numbers and insights
|
||||
3. Eliminate all unnecessary words and context
|
||||
4. Use simple markdown for formatting
|
||||
5. 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
|
||||
- Apologize or explain limitations
|
||||
|
||||
Present monetary values using the format provided by the functions.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
# Ensure the response is concise by truncating if necessary
|
||||
def ensure_concise_response(content)
|
||||
return "" if content.nil? || content.empty?
|
||||
|
||||
# Split the content into sentences
|
||||
sentences = content.split(/(?<=[.!?])\s+/)
|
||||
|
||||
# Handle case where regex doesn't match (e.g., single sentence without ending punctuation)
|
||||
sentences = [ content ] if sentences.empty?
|
||||
|
||||
# Take only the first 3 sentences maximum
|
||||
sentences = sentences[0..2]
|
||||
|
||||
# Join the sentences back together
|
||||
truncated = sentences.join(" ")
|
||||
|
||||
# Further limit by word count (75 words maximum)
|
||||
words = truncated.split(/\s+/)
|
||||
if words.length > 75
|
||||
truncated = words[0...75].join(" ")
|
||||
# Ensure the truncated content ends with proper punctuation
|
||||
truncated = truncated.strip
|
||||
truncated += "." unless truncated.end_with?(".", "!", "?")
|
||||
end
|
||||
|
||||
truncated
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,21 +1,13 @@
|
|||
module Promptable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# The openai ruby gem hasn't yet added support for the responses endpoint.
|
||||
# TODO: Remove this once the gem implements it.
|
||||
class CustomOpenAI < OpenAI::Client
|
||||
def responses(parameters: {})
|
||||
json_post(path: "/responses", parameters: parameters)
|
||||
end
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def openai_client
|
||||
api_key = ENV.fetch("OPENAI_ACCESS_TOKEN", Setting.openai_access_token)
|
||||
|
||||
return nil unless api_key.present?
|
||||
|
||||
CustomOpenAI.new(access_token: api_key)
|
||||
OpenAI::Client.new(access_token: api_key)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
597
app/models/financial_assistant.rb
Normal file
597
app/models/financial_assistant.rb
Normal file
|
@ -0,0 +1,597 @@
|
|||
class FinancialAssistant
|
||||
include Debuggable
|
||||
|
||||
def initialize(chat, llm)
|
||||
@chat = chat
|
||||
@llm = llm
|
||||
end
|
||||
|
||||
def query(question, chat_history = nil)
|
||||
# Log the system prompt in debug mode
|
||||
# if Ai::DebugMode.enabled? && @chat
|
||||
# Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: System prompt", { prompt: system_prompt })
|
||||
# end
|
||||
|
||||
# Build messages array with chat history if provided
|
||||
messages = [ { role: "system", content: system_prompt } ]
|
||||
|
||||
if chat_history.present?
|
||||
# Add previous messages from chat history, excluding system messages
|
||||
messages.concat(
|
||||
chat_history
|
||||
.conversation
|
||||
.ordered
|
||||
.map { |msg| { role: msg.role, content: msg.content } }
|
||||
)
|
||||
|
||||
# If the last message is not the current question, add it
|
||||
if messages.last[:content] != question
|
||||
messages << { role: "user", content: question }
|
||||
end
|
||||
else
|
||||
# Just add the current question if no history
|
||||
messages << { role: "user", content: question }
|
||||
end
|
||||
|
||||
# Log the messages being sent in debug mode
|
||||
# if Ai::DebugMode.enabled? && @chat
|
||||
# Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Messages being sent to AI", { messages: messages })
|
||||
# end
|
||||
|
||||
response = client.chat(
|
||||
parameters: {
|
||||
model: "gpt-4o",
|
||||
messages: messages,
|
||||
tools: financial_function_definitions.map { |func| { type: "function", function: func } },
|
||||
tool_choice: "auto",
|
||||
temperature: 0.5
|
||||
}
|
||||
)
|
||||
|
||||
process_response(response, question, messages)
|
||||
end
|
||||
|
||||
# Set the chat for debug logging
|
||||
def with_chat(chat)
|
||||
@chat = chat
|
||||
self
|
||||
end
|
||||
|
||||
# Define the functions that can be called by GPT
|
||||
def financial_function_definitions
|
||||
[
|
||||
{
|
||||
name: "get_balance_sheet",
|
||||
description: "Get current balance sheet information including net worth, assets, and liabilities",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_income_statement",
|
||||
description: "Get income statement data for a specific time period",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
period: {
|
||||
type: "string",
|
||||
enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ],
|
||||
description: "The time period for the income statement data"
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_expense_categories",
|
||||
description: "Get top expense categories for a specific time period",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
period: {
|
||||
type: "string",
|
||||
enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ],
|
||||
description: "The time period for the expense categories data"
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: "Number of top categories to return",
|
||||
default: 5
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_account_balances",
|
||||
description: "Get balances for all accounts or by account type",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
account_type: {
|
||||
type: "string",
|
||||
enum: [ "asset", "liability", "all" ],
|
||||
description: "Type of accounts to get balances for"
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_transactions",
|
||||
description: "Get transactions filtered by date range and/or category",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
start_date: {
|
||||
type: "string",
|
||||
format: "date",
|
||||
description: "Start date for transactions (YYYY-MM-DD)"
|
||||
},
|
||||
end_date: {
|
||||
type: "string",
|
||||
format: "date",
|
||||
description: "End date for transactions (YYYY-MM-DD)"
|
||||
},
|
||||
category_name: {
|
||||
type: "string",
|
||||
description: "Filter transactions by category name"
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
description: "Maximum number of transactions to return",
|
||||
default: 10
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "compare_periods",
|
||||
description: "Compare financial data between two periods",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
period1: {
|
||||
type: "string",
|
||||
enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ],
|
||||
description: "First period for comparison"
|
||||
},
|
||||
period2: {
|
||||
type: "string",
|
||||
enum: [ "current_month", "previous_month", "year_to_date", "previous_year" ],
|
||||
description: "Second period for comparison"
|
||||
}
|
||||
},
|
||||
required: [ "period1", "period2" ]
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_response(response, original_question, messages)
|
||||
message = response.dig("choices", 0, "message")
|
||||
|
||||
# # Log the raw response in debug mode
|
||||
# if Ai::DebugMode.enabled? && @chat
|
||||
# Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Raw AI response", {
|
||||
# response_type: message["tool_calls"] ? "function_call" : "direct_content",
|
||||
# content: message["content"]
|
||||
# })
|
||||
# end
|
||||
|
||||
# If there are no function calls, ensure the direct response is concise
|
||||
return message["content"] unless message["tool_calls"]
|
||||
|
||||
# Handle function calls
|
||||
function_calls = message["tool_calls"]
|
||||
|
||||
# # Log the function calls in debug mode
|
||||
# if Ai::DebugMode.enabled? && @chat
|
||||
# debug_function_calls = function_calls.map do |call|
|
||||
# {
|
||||
# function_name: call["function"]["name"],
|
||||
# arguments: JSON.parse(call["function"]["arguments"])
|
||||
# }
|
||||
# end
|
||||
|
||||
# Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Function calls", { function_calls: debug_function_calls })
|
||||
# end
|
||||
|
||||
function_results = execute_function_calls(function_calls)
|
||||
|
||||
# Log the function results in debug mode
|
||||
# if Ai::DebugMode.enabled? && @chat
|
||||
# debug_results = function_calls.map.with_index do |call, i|
|
||||
# {
|
||||
# function_name: call["function"]["name"],
|
||||
# result: function_results[i]
|
||||
# }
|
||||
# end
|
||||
|
||||
# Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Function results", { results: debug_results })
|
||||
# end
|
||||
|
||||
# Continue the conversation with function results
|
||||
follow_up_messages = messages.dup
|
||||
|
||||
# Add the assistant's response with function calls
|
||||
follow_up_messages << message
|
||||
|
||||
# Add the function results
|
||||
function_results.each_with_index do |result, index|
|
||||
follow_up_messages << {
|
||||
role: "tool",
|
||||
tool_call_id: function_calls[index]["id"],
|
||||
name: function_calls[index]["function"]["name"],
|
||||
content: result.to_json
|
||||
}
|
||||
end
|
||||
|
||||
# Add a reminder to be concise
|
||||
follow_up_messages << {
|
||||
role: "system",
|
||||
content: "CRITICAL: Eliminate all unnecessary words."
|
||||
}
|
||||
|
||||
# # Log the follow-up request in debug mode
|
||||
# if Ai::DebugMode.enabled? && @chat
|
||||
# Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Follow-up request", { messages: follow_up_messages })
|
||||
# end
|
||||
|
||||
follow_up_response = client.chat(
|
||||
parameters: {
|
||||
model: "gpt-4o",
|
||||
messages: follow_up_messages,
|
||||
temperature: 0.5
|
||||
}
|
||||
)
|
||||
|
||||
# Log the final response in debug mode
|
||||
final_content = follow_up_response.dig("choices", 0, "message", "content")
|
||||
# if Ai::DebugMode.enabled? && @chat
|
||||
# Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Final response", { content: final_content })
|
||||
# end
|
||||
|
||||
# Return the final response
|
||||
final_content
|
||||
end
|
||||
|
||||
def execute_function_calls(function_calls)
|
||||
function_calls.map do |call|
|
||||
function_name = call["function"]["name"]
|
||||
arguments = JSON.parse(call["function"]["arguments"])
|
||||
|
||||
# Log the function execution in debug mode
|
||||
# if Ai::DebugMode.enabled? && @chat
|
||||
# Ai::DebugMode.log_to_chat(@chat, "🐞 DEBUG: Executing function", {
|
||||
# function: function_name,
|
||||
# arguments: arguments
|
||||
# })
|
||||
# end
|
||||
|
||||
result = case function_name
|
||||
when "get_balance_sheet"
|
||||
execute_get_balance_sheet(arguments)
|
||||
when "get_income_statement"
|
||||
execute_get_income_statement(arguments)
|
||||
when "get_expense_categories"
|
||||
execute_get_expense_categories(arguments)
|
||||
when "get_account_balances"
|
||||
execute_get_account_balances(arguments)
|
||||
when "get_transactions"
|
||||
execute_get_transactions(arguments)
|
||||
when "compare_periods"
|
||||
execute_compare_periods(arguments)
|
||||
else
|
||||
{ error: "Unknown function: #{function_name}" }
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
# Execute the get_balance_sheet function
|
||||
def execute_get_balance_sheet(params = {})
|
||||
balance_sheet = BalanceSheet.new(family)
|
||||
balance_sheet.to_ai_readable_hash
|
||||
end
|
||||
|
||||
# Execute the get_income_statement function
|
||||
def execute_get_income_statement(params = {})
|
||||
income_statement = IncomeStatement.new(family)
|
||||
period = get_period_from_param(params["period"])
|
||||
income_statement.to_ai_readable_hash(period: period)
|
||||
end
|
||||
|
||||
# Execute the get_expense_categories function
|
||||
def execute_get_expense_categories(params = {})
|
||||
income_statement = IncomeStatement.new(family)
|
||||
period = get_period_from_param(params["period"])
|
||||
limit = params["limit"] || 5
|
||||
|
||||
expense_data = income_statement.expense_totals(period: period)
|
||||
|
||||
top_categories = expense_data.category_totals
|
||||
.reject { |ct| ct.category.subcategory? }
|
||||
.sort_by { |ct| -ct.total }
|
||||
.take(limit)
|
||||
.map do |ct|
|
||||
{
|
||||
name: ct.category.name,
|
||||
amount: format_currency(ct.total),
|
||||
percentage: ct.weight.round(2)
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
period: {
|
||||
start_date: period.start_date.to_s,
|
||||
end_date: period.end_date.to_s
|
||||
},
|
||||
total_expenses: format_currency(expense_data.total),
|
||||
top_categories: top_categories,
|
||||
currency: family.currency
|
||||
}
|
||||
end
|
||||
|
||||
# Execute the get_account_balances function
|
||||
def execute_get_account_balances(params = {})
|
||||
account_type = params["account_type"] || "all"
|
||||
balance_sheet = BalanceSheet.new(family)
|
||||
|
||||
accounts = case account_type
|
||||
when "asset"
|
||||
balance_sheet.account_groups("asset")
|
||||
when "liability"
|
||||
balance_sheet.account_groups("liability")
|
||||
else
|
||||
balance_sheet.account_groups
|
||||
end
|
||||
|
||||
account_data = accounts.flat_map do |group|
|
||||
group.accounts.map do |account|
|
||||
{
|
||||
name: account.name,
|
||||
type: account.accountable_type,
|
||||
balance: format_currency(account.balance),
|
||||
classification: account.classification
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
as_of_date: Date.today.to_s,
|
||||
currency: family.currency,
|
||||
accounts: account_data
|
||||
}
|
||||
end
|
||||
|
||||
# Execute the get_transactions function
|
||||
def execute_get_transactions(params = {})
|
||||
start_date = params["start_date"] ? Date.parse(params["start_date"]) : 30.days.ago.to_date
|
||||
end_date = params["end_date"] ? Date.parse(params["end_date"]) : Date.today
|
||||
category_name = params["category_name"]
|
||||
limit = params["limit"] || 10
|
||||
|
||||
transactions_query = family.transactions.active.in_period(Period.new(start_date: start_date, end_date: end_date))
|
||||
|
||||
if category_name.present?
|
||||
# Try to find an exact match first
|
||||
category = family.categories.find_by(name: category_name)
|
||||
|
||||
# If no exact match, try fuzzy matching
|
||||
unless category
|
||||
# Try case-insensitive contains matching
|
||||
categories = family.categories.where("LOWER(name) LIKE ?", "%#{category_name.downcase}%")
|
||||
|
||||
# If still no match, try common synonyms
|
||||
if categories.empty?
|
||||
synonyms = {
|
||||
"food" => [ "grocery", "groceries", "supermarket", "dining", "restaurant", "meal" ],
|
||||
"groceries" => [ "food", "grocery", "supermarket" ],
|
||||
"dining" => [ "restaurant", "food", "eating out", "meal" ],
|
||||
"utilities" => [ "utility", "bills", "electricity", "water", "gas" ],
|
||||
"transportation" => [ "travel", "car", "bus", "transit", "commute" ],
|
||||
"shopping" => [ "retail", "clothes", "merchandise" ]
|
||||
# Add more common synonyms as needed
|
||||
}
|
||||
|
||||
matched_categories = []
|
||||
synonyms.each do |formal_term, informal_terms|
|
||||
if category_name.downcase == formal_term.downcase ||
|
||||
informal_terms.any? { |term| category_name.downcase.include?(term.downcase) }
|
||||
matched_categories += family.categories.where("LOWER(name) LIKE ?", "%#{formal_term.downcase}%")
|
||||
end
|
||||
end
|
||||
|
||||
categories = matched_categories.uniq if matched_categories.any?
|
||||
end
|
||||
|
||||
# Use the first matching category if any were found
|
||||
category = categories.first if categories.any?
|
||||
end
|
||||
|
||||
# If we found a category through any matching method, filter by it
|
||||
if category
|
||||
transactions_query = transactions_query.where(category_id: category.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Use eager loading to avoid N+1 queries and ensure all attributes are available
|
||||
transactions_query = transactions_query.includes(:account_entry, :category, :merchant)
|
||||
|
||||
# Specify the table name explicitly to avoid ambiguous column reference
|
||||
transactions = transactions_query.order("account_entries.date DESC").limit(limit)
|
||||
|
||||
transaction_data = transactions.map do |transaction|
|
||||
# Access the date through the entry association
|
||||
entry = transaction.account_entry
|
||||
{
|
||||
date: entry.date,
|
||||
name: entry.name,
|
||||
amount: format_currency(entry.amount),
|
||||
category: transaction.category&.name || "Uncategorized",
|
||||
merchant: transaction.merchant&.name
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
period: {
|
||||
start_date: start_date.to_s,
|
||||
end_date: end_date.to_s
|
||||
},
|
||||
transactions: transaction_data,
|
||||
count: transaction_data.size,
|
||||
currency: family.currency,
|
||||
search_info: {
|
||||
category_query: category_name,
|
||||
matched_category: category&.name
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# Execute the compare_periods function
|
||||
def execute_compare_periods(params = {})
|
||||
period1 = get_period_from_param(params["period1"])
|
||||
period2 = get_period_from_param(params["period2"])
|
||||
|
||||
income_statement = IncomeStatement.new(family)
|
||||
|
||||
period1_data = {
|
||||
income: income_statement.income_totals(period: period1),
|
||||
expenses: income_statement.expense_totals(period: period1)
|
||||
}
|
||||
|
||||
period2_data = {
|
||||
income: income_statement.income_totals(period: period2),
|
||||
expenses: income_statement.expense_totals(period: period2)
|
||||
}
|
||||
|
||||
# Calculate differences
|
||||
income_diff = period1_data[:income].total - period2_data[:income].total
|
||||
expenses_diff = period1_data[:expenses].total - period2_data[:expenses].total
|
||||
net_income_diff = income_diff - expenses_diff
|
||||
|
||||
# Calculate percentage changes
|
||||
income_pct_change = period2_data[:income].total > 0 ? (income_diff / period2_data[:income].total.to_f * 100).round(2) : 0
|
||||
expenses_pct_change = period2_data[:expenses].total > 0 ? (expenses_diff / period2_data[:expenses].total.to_f * 100).round(2) : 0
|
||||
|
||||
{
|
||||
period1: {
|
||||
name: period_name(params["period1"]),
|
||||
start_date: period1.start_date.to_s,
|
||||
end_date: period1.end_date.to_s,
|
||||
total_income: format_currency(period1_data[:income].total),
|
||||
total_expenses: format_currency(period1_data[:expenses].total),
|
||||
net_income: format_currency(period1_data[:income].total - period1_data[:expenses].total)
|
||||
},
|
||||
period2: {
|
||||
name: period_name(params["period2"]),
|
||||
start_date: period2.start_date.to_s,
|
||||
end_date: period2.end_date.to_s,
|
||||
total_income: format_currency(period2_data[:income].total),
|
||||
total_expenses: format_currency(period2_data[:expenses].total),
|
||||
net_income: format_currency(period2_data[:income].total - period2_data[:expenses].total)
|
||||
},
|
||||
differences: {
|
||||
income: format_currency(income_diff),
|
||||
income_percent: income_pct_change,
|
||||
expenses: format_currency(expenses_diff),
|
||||
expenses_percent: expenses_pct_change,
|
||||
net_income: format_currency(net_income_diff)
|
||||
},
|
||||
currency: family.currency
|
||||
}
|
||||
end
|
||||
|
||||
# Helper to convert period string to a Period object
|
||||
def get_period_from_param(period_param)
|
||||
case period_param
|
||||
when "current_month"
|
||||
Period.current_month
|
||||
when "previous_month"
|
||||
Period.previous_month
|
||||
when "year_to_date"
|
||||
Period.year_to_date
|
||||
when "previous_year"
|
||||
Period.previous_year
|
||||
else
|
||||
Period.current_month
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to get human-readable period name
|
||||
def period_name(period_param)
|
||||
case period_param
|
||||
when "current_month"
|
||||
"Current Month"
|
||||
when "previous_month"
|
||||
"Previous Month"
|
||||
when "year_to_date"
|
||||
"Year to Date"
|
||||
when "previous_year"
|
||||
"Previous Year"
|
||||
else
|
||||
"Custom Period"
|
||||
end
|
||||
end
|
||||
|
||||
# Format currency values consistently for AI display
|
||||
def format_currency(amount, currency = family.currency)
|
||||
Money.new(amount, currency).format
|
||||
end
|
||||
|
||||
# System prompt for the GPT model
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are a helpful financial assistant for Maybe, a personal finance app.
|
||||
You help users understand their financial data by answering questions about their accounts, transactions, income, expenses, and net worth.
|
||||
|
||||
When users ask financial questions:
|
||||
1. Use the provided functions to retrieve the relevant data
|
||||
2. Provide ONLY the most important numbers and insights
|
||||
3. Eliminate all unnecessary words and context
|
||||
4. Use simple markdown for formatting
|
||||
5. 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
|
||||
- Apologize or explain limitations
|
||||
|
||||
Present monetary values using the format provided by the functions.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
# Ensure the response is concise by truncating if necessary
|
||||
def ensure_concise_response(content)
|
||||
return "" if content.nil? || content.empty?
|
||||
|
||||
# Split the content into sentences
|
||||
sentences = content.split(/(?<=[.!?])\s+/)
|
||||
|
||||
# Handle case where regex doesn't match (e.g., single sentence without ending punctuation)
|
||||
sentences = [ content ] if sentences.empty?
|
||||
|
||||
# Take only the first 3 sentences maximum
|
||||
sentences = sentences[0..2]
|
||||
|
||||
# Join the sentences back together
|
||||
truncated = sentences.join(" ")
|
||||
|
||||
# Further limit by word count (75 words maximum)
|
||||
words = truncated.split(/\s+/)
|
||||
if words.length > 75
|
||||
truncated = words[0...75].join(" ")
|
||||
# Ensure the truncated content ends with proper punctuation
|
||||
truncated = truncated.strip
|
||||
truncated += "." unless truncated.end_with?(".", "!", "?")
|
||||
end
|
||||
|
||||
truncated
|
||||
end
|
||||
end
|
38
app/models/financial_assistant/debuggable.rb
Normal file
38
app/models/financial_assistant/debuggable.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
module FinancialAssistant::Debuggable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Check if debug mode is enabled
|
||||
def self.enabled?
|
||||
ENV["AI_DEBUG_MODE"] == "true"
|
||||
end
|
||||
|
||||
# Log debug information to a chat
|
||||
def self.log_to_chat(chat, message, data = nil)
|
||||
return unless enabled?
|
||||
|
||||
# Store debug messages in the database but don't output to chat
|
||||
content = message
|
||||
if data.present?
|
||||
# Limit the size of the JSON data to prevent PostgreSQL NOTIFY payload size limit errors
|
||||
if data.is_a?(Hash) && data[:backtrace].is_a?(Array)
|
||||
# Limit backtrace to first 3 entries to reduce payload size
|
||||
data[:backtrace] = data[:backtrace].first(3)
|
||||
end
|
||||
|
||||
# Convert to JSON and check size
|
||||
json_data = JSON.pretty_generate(data)
|
||||
|
||||
# If still too large, truncate it (PostgreSQL NOTIFY has ~8000 byte limit)
|
||||
if json_data.bytesize > 7000
|
||||
json_data = json_data[0...7000] + "\n... (truncated due to size limits)"
|
||||
end
|
||||
|
||||
content += "\n\n```json\n#{json_data}\n```"
|
||||
end
|
||||
|
||||
chat.messages.create!(
|
||||
role: "developer",
|
||||
content: content,
|
||||
)
|
||||
end
|
||||
end
|
|
@ -26,10 +26,17 @@ class Message < ApplicationRecord
|
|||
scope :ordered, -> { order(created_at: :asc) }
|
||||
|
||||
private
|
||||
def requires_response?
|
||||
user? && text?
|
||||
end
|
||||
|
||||
def broadcast_and_fetch
|
||||
broadcast_append_to chat
|
||||
sleep 2
|
||||
# broadcast_append_to chat, target: "messages", partial: "messages/thinking_message"
|
||||
# sleep 2
|
||||
|
||||
if user?
|
||||
if requires_response?
|
||||
stream_openai_response
|
||||
end
|
||||
end
|
||||
|
@ -37,6 +44,14 @@ class Message < ApplicationRecord
|
|||
def stream_openai_response
|
||||
# TODO
|
||||
Rails.logger.info "Streaming OpenAI response"
|
||||
|
||||
# broadcast_remove_to chat, target: "thinking-message"
|
||||
|
||||
self.class.create!(
|
||||
chat: chat,
|
||||
role: "assistant",
|
||||
content: "Mock OpenAI response message"
|
||||
)
|
||||
end
|
||||
|
||||
def streamer
|
||||
|
|
9
app/models/provider/openai.rb
Normal file
9
app/models/provider/openai.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
class Provider::OpenAI
|
||||
def initialize(access_token)
|
||||
@client = OpenAI::Client.new(access_token: @access_token)
|
||||
end
|
||||
|
||||
def responses(params = {})
|
||||
client.responses(parameters: params)
|
||||
end
|
||||
end
|
|
@ -1,19 +1,17 @@
|
|||
<%= turbo_frame_tag chat_frame do %>
|
||||
<%= turbo_stream_from @chat %>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="p-4">
|
||||
<%= render "chats/chat_nav", chat: @chat %>
|
||||
</div>
|
||||
|
||||
<div id="<%= dom_id(@chat, :messages) %>" class="grow py-8 overflow-y-auto" data-chat-target="messages">
|
||||
<div id="messages" class="grow py-8 overflow-y-auto" data-chat-target="messages">
|
||||
<div class="p-4 space-y-6">
|
||||
<% if @chat.messages.conversation.any? %>
|
||||
<% @chat.messages.conversation.ordered.each do |message| %>
|
||||
<%= render "messages/message", message: message %>
|
||||
<% end %>
|
||||
|
||||
<% if params[:thinking] %>
|
||||
<%= render "messages/thinking_message" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="mt-auto">
|
||||
<%= render "chats/ai_greeting", context: 'chat' %>
|
||||
|
|
|
@ -74,8 +74,6 @@
|
|||
<%= tag.div id: "chat-container",
|
||||
class: class_names("flex flex-col justify-between shrink-0 transition-all duration-300", right_sidebar_open ? "w-[400px]" : "w-0"),
|
||||
data: { controller: "chat hotkey", sidebar_target: "rightPanel", turbo_permanent: true } do %>
|
||||
<%# All chat broadcasts are sent to this centralized user-level stream %>
|
||||
<%= turbo_stream_from Current.user, "chat" %>
|
||||
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<%= lucide_icon("loader-circle", class: "w-5 h-5 text-secondary animate-spin") %>
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
<%# locals: (message: "Thinking ...") -%>
|
||||
|
||||
<div id="thinking-message" class="flex items-start">
|
||||
<%= render "chats/ai_avatar" %>
|
||||
<p class="text-sm text-secondary animate-pulse">Thinking...</p>
|
||||
<p class="text-sm text-secondary animate-pulse"><%= message %></p>
|
||||
</div>
|
|
@ -1,11 +1,3 @@
|
|||
<%= turbo_stream.append dom_id(@chat, :messages) do %>
|
||||
<%= render "messages/message", message: @message %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream.append dom_id(@chat, :messages) do %>
|
||||
<%= render "messages/thinking_message" %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream.replace "chat-form" do %>
|
||||
<%= render "messages/chat_form", chat: @chat %>
|
||||
<% end %>
|
Loading…
Add table
Add a link
Reference in a new issue