1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-26 00:29:40 +02:00
Maybe/app/models/provider/synth.rb
Zach Gollwitzer 2f6b11c18f
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>
2025-03-28 13:08:22 -04:00

251 lines
6.6 KiB
Ruby

class Provider::Synth < Provider
include ExchangeRateProvider, SecurityProvider
def initialize(api_key)
@api_key = api_key
end
def healthy?
with_provider_response do
response = client.get("#{base_url}/user")
JSON.parse(response.body).dig("id").present?
end
end
def usage
with_provider_response do
response = client.get("#{base_url}/user")
parsed = JSON.parse(response.body)
remaining = parsed.dig("api_calls_remaining")
limit = parsed.dig("api_limit")
used = limit - remaining
UsageData.new(
used: used,
limit: limit,
utilization: used.to_f / limit * 100,
plan: parsed.dig("plan"),
)
end
end
# ================================
# Exchange Rates
# ================================
def fetch_exchange_rate(from:, to:, date:)
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
req.params["to"] = to
end
rates = JSON.parse(response.body).dig("data", "rates")
Rate.new(date:, from:, to:, rate: rates.dig(to))
end
end
def fetch_exchange_rates(from:, to:, start_date:, end_date:)
with_provider_response retries: 1 do
data = paginate(
"#{base_url}/rates/historical-range",
from: from,
to: to,
date_start: start_date.to_s,
date_end: end_date.to_s
) do |body|
body.dig("data")
end
data.paginated.map do |rate|
Rate.new(date: rate.dig("date"), from:, to:, rate: rate.dig("rates", to))
end
end
end
# ================================
# Securities
# ================================
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
with_provider_response do
response = client.get("#{base_url}/tickers/search") do |req|
req.params["name"] = symbol
req.params["dataset"] = "limited"
req.params["country_code"] = country_code if country_code.present?
req.params["exchange_operating_mic"] = exchange_operating_mic if exchange_operating_mic.present?
req.params["limit"] = 25
end
parsed = JSON.parse(response.body)
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)
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?
end
data = JSON.parse(response.body).dig("data")
SecurityInfo.new(
symbol: data.dig("ticker"),
name: data.dig("name"),
links: data.dig("links"),
logo_url: data.dig("logo_url"),
description: data.dig("description"),
kind: data.dig("kind")
)
end
end
def fetch_security_price(security, date:)
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.empty?
historical_data.data.first
end
end
def fetch_security_prices(security, start_date:, end_date:)
with_provider_response retries: 1 do
params = {
start_date: start_date,
end_date: end_date
}
params[:operating_mic_code] = security.exchange_operating_mic if security.exchange_operating_mic.present?
data = paginate(
"#{base_url}/tickers/#{security.ticker}/open-close",
params
) do |body|
body.dig("prices")
end
currency = data.first_page.dig("currency")
country_code = data.first_page.dig("exchange", "country_code")
exchange_mic = data.first_page.dig("exchange", "mic_code")
exchange_operating_mic = data.first_page.dig("exchange", "operating_mic_code")
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
# ================================
# Transactions
# ================================
def enrich_transaction(description, amount: nil, date: nil, city: nil, state: nil, country: nil)
with_provider_response do
params = {
description: description,
amount: amount,
date: date,
city: city,
state: state,
country: country
}.compact
response = client.get("#{base_url}/enrich", params)
parsed = JSON.parse(response.body)
TransactionEnrichmentData.new(
name: parsed.dig("merchant"),
icon_url: parsed.dig("icon"),
category: parsed.dig("category")
)
end
end
private
attr_reader :api_key
TransactionEnrichmentData = Data.define(:name, :icon_url, :category)
def retryable_errors
[
Faraday::TimeoutError,
Faraday::ConnectionFailed,
Faraday::SSLError
]
end
def base_url
ENV["SYNTH_URL"] || "https://api.synthfinance.com"
end
def app_name
"maybe_app"
end
def app_type
Rails.application.config.app_mode
end
def client
@client ||= Faraday.new(url: base_url) do |faraday|
faraday.response :raise_error
faraday.headers["Authorization"] = "Bearer #{api_key}"
faraday.headers["X-Source"] = app_name
faraday.headers["X-Source-Type"] = app_type
end
end
def fetch_page(url, page, params = {})
client.get(url, params.merge(page: page))
end
def paginate(url, params = {})
results = []
page = 1
current_page = 0
total_pages = 1
first_page = nil
while current_page < total_pages
response = fetch_page(url, page, params)
body = JSON.parse(response.body)
first_page = body unless first_page
page_results = yield(body)
results.concat(page_results)
current_page = body.dig("paging", "current_page")
total_pages = body.dig("paging", "total_pages")
page += 1
end
PaginatedData.new(
paginated: results,
first_page: first_page,
total_pages: total_pages
)
end
end