diff --git a/.cursor/rules/general-rules.mdc b/.cursor/rules/general-rules.mdc new file mode 100644 index 00000000..7335792d --- /dev/null +++ b/.cursor/rules/general-rules.mdc @@ -0,0 +1,23 @@ +--- +description: Miscellaneous rules to get the AI to behave +globs: * +alwaysApply: true +--- +# General rules for AI + +- Use `Current.user` for the current user. Do NOT use `current_user`. +- Use `Current.family` for the current family. Do NOT use `current_family`. +- Prior to generating any code, carefully read the project conventions and guidelines + - Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase + - Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase + - Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically + +## Prohibited actions + +Do not under any circumstance do the following: + +- Do not run `rails server` in your responses. +- Do not run `touch tmp/restart.txt` +- Do not run `rails credentials` +- Do not automatically run migrations +- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development. \ No newline at end of file diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index 32ecf705..2977dc33 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -3,13 +3,7 @@ description: globs: alwaysApply: true --- -This rule serves as high-level documentation for how the Maybe codebase is structured. - -## Rules for AI - -- Use this file to understand how the codebase works -- Treat this rule/file as your "source of truth" when making code recommendations -- When creating migrations, always use `rails g migration` instead of creating the file yourself +This rule serves as high-level documentation for how you should write code for the Maybe codebase. ## Project Tech Stack @@ -19,6 +13,7 @@ This rule serves as high-level documentation for how the Maybe codebase is struc - Hotwire Turbo/Stimulus for SPA-like UI/UX - TailwindCSS for styles - Lucide Icons for icons + - OpenAI for AI chat - Database: PostgreSQL - Jobs: Sidekiq + Redis - External @@ -47,39 +42,79 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore, - When concerns are used for code organization, they should be organized around the "traits" of a model; not for simply moving code to another spot in the codebase. - When possible, models should answer questions about themselves—for example, we might have a method, `account.balance_series` that returns a time-series of the account's most recent balances. We prefer this over something more service-like such as `AccountSeries.new(account).call`. -### Convention 3: Prefer server-side solutions over client-side solutions +### Convention 3: Leverage Hotwire, write semantic HTML, CSS, and JS, prefer server-side solutions -- When possible, leverage Turbo frames over complex, JS-driven client-side solutions -- When writing a client-side solution, use Stimulus controllers and keep it simple! -- Especially when dealing with money and currencies, calculate + format server-side and then pass that to the client to display -- Keep client-side code for where it truly shines. For example, [bulk_select_controller.js](mdc:app/javascript/controllers/bulk_select_controller.js) is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this. - -### Convention 4: Sacrifice performance, optimize for simplicitly and clarity - -This codebase is still young. We are still rapidly iterating on domain designs and features. Because of this, code should be optimized for simplicitly and clarity over performance. - -- Focus on good OOP design first, performance second -- Be mindful of large performance bottlenecks, but don't sweat the small stuff - -### Convention 5: Prefer semantic, native HTML features - -The HTML spec has improved tremendously over the years and offers a ton of functionality out of the box. We prefer semantic, native HTML solutions over JS-based ones. A few examples of this include: - -- Using the `dialog` element for modals -- Using `summary` / `details` elements for disclosures (or `popover` attribute) +- Native HTML is always preferred over JS-based components + - Example 1: Use `` element for modals instead of creating a custom component + - Example 2: Use `
...
` for disclosures rather than custom components +- Leverage Turbo frames to break up the page over JS-driven client-side solutions + - Example 1: A good example of turbo frame usage is in [application.html.erb](mdc:app/views/layouts/application.html.erb) where we load [chats_controller.rb](mdc:app/controllers/chats_controller.rb) actions in a turbo frame in the global layout +- Leverage query params in the URL for state over local storage and sessions. If absolutely necessary, utilize the DB for persistent state. +- Use Turbo streams to enhance functionality, but do not solely depend on it +- Format currencies, numbers, dates, and other values server-side, then pass to Stimulus controllers for display only +- Keep client-side code for where it truly shines. For example, @bulk_select_controller.js is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this. The Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this. -### Convention 6: Use Minitest + Fixtures for testing, minimize fixtures +### Convention 4: Optimize for simplicitly and clarity + +All code should maximize readability and simplicity. + +- Prioritize good OOP domain design over performance +- Only focus on performance for critical and global areas of the codebase; otherwise, don't sweat the small stuff. + - Example 1: be mindful of loading large data payloads in global layouts + - Example 2: Avoid N+1 queries + +### Convention 5: Use Minitest + Fixtures for testing, minimize fixtures Due to the open-source nature of this project, we have chosen Minitest + Fixtures for testing to maximize familiarity and predictability. - Always use Minitest and fixtures for testing. - Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed. -- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [balance_calculator_test.rb](mdc:test/models/account/balance_calculator_test.rb) +- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb) +- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence -### Convention 7: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB +#### Convention 5a: Write minimal, effective tests + +- Use system tests sparingly as they increase the time to complete the test suite +- Only write tests for critical and important code paths +- Write tests as you go, when required +- Take a practical approach to testing. Tests are effective when their presence _significantly increases confidence in the codebase_. + +Below are examples of necessary vs. unnecessary tests: + +```rb +# GOOD!! +# Necessary test - in this case, we're testing critical domain business logic +test "syncs balances" do + Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once + + @account.expects(:start_date).returns(2.days.ago.to_date) + + Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns( + [ + Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), + Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") + ] + ) + + assert_difference "@account.balances.count", 2 do + Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances + end +end + +# BAD!! +# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality +test "saves balance" do + balance_record = Account::Balance.new(balance: 100, currency: "USD") + + assert balance_record.save +end +``` + +### Convention 6: Use ActiveRecord for complex validations, DB for simple ones, keep business logic out of DB - Enforce `null` checks, unique indexes, and other simple validations in the DB - ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible. -- Complex validations and business logic should remain in ActiveRecord \ No newline at end of file +- Complex validations and business logic should remain in ActiveRecord + diff --git a/.cursor/rules/project-design.mdc b/.cursor/rules/project-design.mdc index 6d4f2091..41fa2210 100644 --- a/.cursor/rules/project-design.mdc +++ b/.cursor/rules/project-design.mdc @@ -1,7 +1,7 @@ --- description: This rule explains the system architecture and data flow of the Rails app globs: * -alwaysApply: false +alwaysApply: true --- This file outlines how the codebase is structured and how data flows through the app. @@ -111,12 +111,12 @@ Below are brief descriptions of each type of sync in more detail. ### Account Syncs -The most important type of sync is the account sync. It is orchestrated by the account [syncer.rb](mdc:app/models/account/syncer.rb), and performs a few important tasks: +The most important type of sync is the account sync. It is orchestrated by the account's `sync_data` method, which performs a few important tasks: - Auto-matches transfer records for the account -- Calculates holdings and balances for the account -- Enriches transaction data -- Converts account balances that are not in the family's preferred currency to the preferred currency +- Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb) + - Balances are dependent on the calculation of [holding.rb](mdc:app/models/account/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb) +- Enriches transaction data if enabled by user An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated. @@ -136,21 +136,7 @@ A family sync happens once daily via [auto_sync.rb](mdc:app/controllers/concerns The Maybe app utilizes several 3rd party data services to calculate historical account balances, enrich data, and more. Since the app can be run in both "hosted" and "self hosted" mode, this means that data providers are _optional_ for self hosted users and must be configured. -Because of this optionality, data providers must be configured at _runtime_ through the [providers.rb](mdc:app/models/providers.rb) module, utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys: - -```rb -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 -end -``` +Because of this optionality, data providers must be configured at _runtime_ through [registry.rb](mdc:app/models/provider/registry.rb) utilizing [setting.rb](mdc:app/models/setting.rb) for runtime parameters like API keys: There are two types of 3rd party data in the Maybe app: @@ -161,74 +147,35 @@ There are two types of 3rd party data in the Maybe app: Since the app is self hostable, users may prefer using different providers for generic data like exchange rates and security prices. When data is generic enough where we can easily swap out different providers, we call it a data "concept". -Each "concept" _must_ have a `Provideable` concern that defines the methods that must be implemented along with the data shapes that are returned. For example, an "exchange rates concept" might look like this: +Each "concept" has an interface defined in the `app/models/provider/concepts` directory. ``` app/models/ - exchange_rate.rb # <- ActiveRecord model and "concept" exchange_rate/ - provided.rb # <- Chooses the provider for this concept based on user settings / config - provideable.rb # <- Defines interface for providing exchange rates + provided.rb # <- Responsible for selecting the concept provider from the registry provider.rb # <- Base provider class provider/ + registry.rb <- Defines available providers by concept + concepts/ + exchange_rate.rb <- defines the interface required for the exchange rate concept synth.rb # <- Concrete provider implementation ``` -Where the `Provideable` and concrete provider implementations would be something like: - -```rb -# Defines the interface an exchange rate provider must implement -module ExchangeRate::Provideable - 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 - - def fetch_exchange_rates(from:, to:, start_date:, end_date:) - raise NotImplementedError, "Subclasses must implement #fetch_exchange_rates" - end -end -``` - -Any provider that is a valid exchange rate provider must implement this interface: - -```rb -class ConcreteProvider < Provider - include ExchangeRate::Provideable - - def fetch_exchange_rate(from:, to:, date:) - provider_response do - ExchangeRate::Provideable::FetchRateData.new( - rate: ExchangeRate.new # build response - ) - end - end - - def fetch_exchange_rates(from:, to:, start_date:, end_date:) - # Implementation - end -end -``` - ### One-off data -For data that does not fit neatly into a "concept", a `Provideable` is not required and the concrete provider may implement ad-hoc methods called directly in code. For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider. This should be called directly without any abstractions: +For data that does not fit neatly into a "concept", an interface is not required and the concrete provider may implement ad-hoc methods called directly in code. For example, the [synth.rb](mdc:app/models/provider/synth.rb) provider has a `usage` method that is only applicable to this specific provider. This should be called directly without any abstractions: ```rb class SomeModel < Application def synth_usage - Providers.synth.usage + Provider::Registry.get_provider(:synth)&.usage end end ``` ## "Provided" Concerns -In general, domain models should not be calling [providers.rb](mdc:app/models/providers.rb) (`Providers.some_provider`) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for: +In general, domain models should not be calling [registry.rb](mdc:app/models/provider/registry.rb) directly. When 3rd party data is required for a domain model, we use the `Provided` concern within that model's namespace. This concern is primarily responsible for: - Choosing the provider to use for this "concept" - Providing convenience methods on the model for accessing data @@ -241,7 +188,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) @@ -269,12 +217,12 @@ end ## Concrete provider implementations -Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `provider_response`, which will return a `Provider::ProviderResponse` object: +Each 3rd party data provider should have a class under the `Provider::` namespace that inherits from `Provider` and returns `with_provider_response`, which will return a `Provider::ProviderResponse` object: ```rb class ConcreteProvider < Provider def fetch_some_data - provider_response do + with_provider_response do ExampleData.new( example: "data" ) @@ -283,12 +231,12 @@ class ConcreteProvider < Provider end ``` -The `provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible: +The `with_provider_response` automatically catches provider errors, so concrete provider classes should raise when valid data is not possible: ```rb class ConcreteProvider < Provider def fetch_some_data - provider_response do + with_provider_response do data = nil # Raise an error if data cannot be returned diff --git a/.cursor/rules/ui-ux-design-guidelines.mdc b/.cursor/rules/ui-ux-design-guidelines.mdc index a6ad9158..430959d6 100644 --- a/.cursor/rules/ui-ux-design-guidelines.mdc +++ b/.cursor/rules/ui-ux-design-guidelines.mdc @@ -1,13 +1,22 @@ --- description: This file describes Maybe's design system and how views should be styled globs: app/views/**,app/helpers/**,app/javascript/controllers/** +alwaysApply: true --- -Use this rule whenever you are writing html, css, or even styles in Stimulus controllers that use D3.js. +Use the rules below when: + +- You are writing HTML +- You are writing CSS +- You are writing styles in a JavaScript Stimulus controller + +## Rules for AI (mandatory) The codebase uses TailwindCSS v4.x (the newest version) with a custom design system defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) -- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives and tokens we use in the codebase -- Always generate semantic HTML +- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase +- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible. + - Example 1: use `text-primary` rather than `text-gray-900` + - Example 2: use `bg-container` rather than `bg-white` + - Example 3: use `border border-primary` rather than `border border-gray-200` - Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so -- Always favor the "utility first" Tailwind approach. Reusable style classes should not be created often. Code should be reused primarily through ERB partials. -- Always prefer using the utility "tokens" defined in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) when possible. For example, use `text-primary` rather than `text-gray-900`. \ No newline at end of file +- Always generate semantic HTML \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3a37df24..010505c1 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,8 @@ gcp-storage-keyfile.json coverage .cursorrules +.cursor/rules/structure.mdc +.cursor/rules/agent.mdc # Ignore node related files node_modules diff --git a/Gemfile b/Gemfile index 7ff6348f..7d219c27 100644 --- a/Gemfile +++ b/Gemfile @@ -60,6 +60,9 @@ gem "rotp", "~> 6.3" gem "rqrcode", "~> 2.2" gem "activerecord-import" +# AI +gem "ruby-openai" + group :development, :test do gem "debug", platforms: %i[mri windows] gem "brakeman", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 2068c457..c3e5ceaf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -83,15 +83,16 @@ GEM tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) - ast (2.4.2) + ast (2.4.3) aws-eventstream (1.3.2) - aws-partitions (1.1067.0) - aws-sdk-core (3.220.1) + aws-partitions (1.1073.0) + aws-sdk-core (3.221.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 jmespath (~> 1, >= 1.6.1) + logger aws-sdk-kms (1.99.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) @@ -157,6 +158,7 @@ GEM rubocop (>= 1) smart_properties erubi (1.13.1) + event_stream_parser (1.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) faraday (2.12.2) @@ -280,28 +282,28 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) - nokogiri (1.18.4-aarch64-linux-gnu) + nokogiri (1.18.6-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.4-aarch64-linux-musl) + nokogiri (1.18.6-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.4-arm-linux-gnu) + nokogiri (1.18.6-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.4-arm-linux-musl) + nokogiri (1.18.6-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.4-arm64-darwin) + nokogiri (1.18.6-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.4-x86_64-darwin) + nokogiri (1.18.6-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.4-x86_64-linux-gnu) + nokogiri (1.18.6-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.4-x86_64-linux-musl) + nokogiri (1.18.6-x86_64-linux-musl) racc (~> 1.4) octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) pagy (9.3.4) parallel (1.26.3) - parser (3.3.7.1) + parser (3.3.7.2) ast (~> 2.4.1) racc pg (1.5.9) @@ -314,7 +316,7 @@ GEM pp (0.6.2) prettyprint prettyprint (0.2.0) - prism (1.3.0) + prism (1.4.0) propshaft (1.1.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -377,9 +379,9 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rbs (3.8.1) + rbs (3.9.1) logger - rdoc (6.12.0) + rdoc (6.13.0) psych (>= 4.0.0) redcarpet (3.6.1) redis (5.4.0) @@ -406,8 +408,8 @@ GEM rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.39.0) - parser (>= 3.3.1.0) + rubocop-ast (1.41.0) + parser (>= 3.3.7.2) rubocop-performance (1.24.0) lint_roller (~> 1.1) rubocop (>= 1.72.1, < 2.0) @@ -422,13 +424,17 @@ GEM rubocop (>= 1.72) rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) - ruby-lsp (0.23.11) + ruby-lsp (0.23.12) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) rbs (>= 3, < 4) sorbet-runtime (>= 0.5.10782) ruby-lsp-rails (0.4.0) ruby-lsp (>= 0.23.0, < 0.24.0) + ruby-openai (8.0.0) + event_stream_parser (>= 0.3.0, < 2.0.0) + faraday (>= 1) + faraday-multipart (>= 1) ruby-progressbar (1.13.0) ruby-vips (2.2.3) ffi (~> 1.12) @@ -467,21 +473,21 @@ GEM simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) smart_properties (1.17.0) - sorbet-runtime (0.5.11934) + sorbet-runtime (0.5.11953) stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.5) stripe (13.5.0) - tailwindcss-rails (4.2.0) + tailwindcss-rails (4.2.1) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.0.14) - tailwindcss-ruby (4.0.14-aarch64-linux-gnu) - tailwindcss-ruby (4.0.14-aarch64-linux-musl) - tailwindcss-ruby (4.0.14-arm64-darwin) - tailwindcss-ruby (4.0.14-x86_64-darwin) - tailwindcss-ruby (4.0.14-x86_64-linux-gnu) - tailwindcss-ruby (4.0.14-x86_64-linux-musl) + tailwindcss-ruby (4.0.15) + tailwindcss-ruby (4.0.15-aarch64-linux-gnu) + tailwindcss-ruby (4.0.15-aarch64-linux-musl) + tailwindcss-ruby (4.0.15-arm64-darwin) + tailwindcss-ruby (4.0.15-x86_64-darwin) + tailwindcss-ruby (4.0.15-x86_64-linux-gnu) + tailwindcss-ruby (4.0.15-x86_64-linux-musl) terminal-table (4.0.0) unicode-display_width (>= 1.1.1, < 4) thor (1.3.2) @@ -518,15 +524,12 @@ GEM zeitwerk (2.7.2) PLATFORMS - aarch64-linux aarch64-linux-gnu aarch64-linux-musl - arm-linux arm-linux-gnu arm-linux-musl arm64-darwin x86_64-darwin - x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -574,6 +577,7 @@ DEPENDENCIES rqrcode (~> 2.2) rubocop-rails-omakase ruby-lsp-rails + ruby-openai selenium-webdriver sentry-rails sentry-ruby diff --git a/app/assets/images/ai.svg b/app/assets/images/ai.svg new file mode 100644 index 00000000..ee2c6462 --- /dev/null +++ b/app/assets/images/ai.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css deleted file mode 100644 index dcd72732..00000000 --- a/app/assets/stylesheets/application.css +++ /dev/null @@ -1 +0,0 @@ -/* Application styles */ diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 7159c950..4b78a2db 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -8,7 +8,7 @@ @plugin "@tailwindcss/typography"; @plugin "@tailwindcss/forms"; -@import "../stylesheets/simonweb_pickr.css"; +@import "./simonweb_pickr.css"; @layer components { .pcr-app{ @@ -112,6 +112,30 @@ } } +.prose--ai-chat { + @apply break-words; + + p, li { + @apply text-sm text-primary; + } + + scrollbar-width: thin; + scrollbar-color: rgba(156, 163, 175, 0.5) transparent; + + ::-webkit-scrollbar { + width: 6px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.5); + border-radius: 3px; + } +} + /* Custom scrollbar implementation for Windows browsers */ .windows { ::-webkit-scrollbar { @@ -141,4 +165,5 @@ &::-webkit-scrollbar-thumb:hover { background: #a6a6a6; } -} \ No newline at end of file +} +/* The following Markdown CSS has been removed as requested */ diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index c37eb286..ca4f1bbe 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -316,8 +316,8 @@ } @layer base { - form>button { - @apply cursor-pointer; + button { + @apply cursor-pointer focus-visible:outline-gray-900; } hr { diff --git a/app/assets/stylesheets/simonweb_pickr.css b/app/assets/tailwind/simonweb_pickr.css similarity index 100% rename from app/assets/stylesheets/simonweb_pickr.css rename to app/assets/tailwind/simonweb_pickr.css diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index be739d23..70895a3c 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,10 +1,11 @@ class ApplicationController < ActionController::Base - include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable + include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable include Pagy::Backend helper_method :require_upgrade?, :subscription_pending? before_action :detect_os + before_action :set_default_chat private def require_upgrade? @@ -33,4 +34,10 @@ class ApplicationController < ActionController::Base else "" end end + + # By default, we show the user the last chat they interacted with + def set_default_chat + @last_viewed_chat = Current.user&.last_viewed_chat + @chat = @last_viewed_chat + end end diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb new file mode 100644 index 00000000..61909200 --- /dev/null +++ b/app/controllers/chats_controller.rb @@ -0,0 +1,67 @@ +class ChatsController < ApplicationController + include ActionView::RecordIdentifier + + guard_feature unless: -> { Current.user.ai_enabled? } + + before_action :set_chat, only: [ :show, :edit, :update, :destroy ] + + def index + @chat = nil # override application_controller default behavior of setting @chat to last viewed chat + @chats = Current.user.chats.order(created_at: :desc) + end + + def show + set_last_viewed_chat(@chat) + end + + def new + @chat = Current.user.chats.new(title: "New chat #{Time.current.strftime("%Y-%m-%d %H:%M")}") + end + + 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, thinking: true) + end + + def edit + end + + def update + @chat.update!(chat_params) + + respond_to do |format| + format.html { redirect_back_or_to chat_path(@chat), notice: "Chat updated" } + format.turbo_stream { render turbo_stream: turbo_stream.replace(dom_id(@chat, :title), partial: "chats/chat_title", locals: { chat: @chat }) } + end + end + + def destroy + @chat.destroy + clear_last_viewed_chat + + redirect_to chats_path, notice: "Chat was successfully deleted" + end + + def retry + @chat.retry_last_message! + redirect_to chat_path(@chat, thinking: true) + end + + private + def set_chat + @chat = Current.user.chats.find(params[:id]) + end + + def set_last_viewed_chat(chat) + Current.user.update!(last_viewed_chat: chat) + end + + def clear_last_viewed_chat + Current.user.update!(last_viewed_chat: nil) + end + + def chat_params + params.require(:chat).permit(:title, :content, :ai_model) + end +end diff --git a/app/controllers/concerns/feature_guardable.rb b/app/controllers/concerns/feature_guardable.rb new file mode 100644 index 00000000..d08f957d --- /dev/null +++ b/app/controllers/concerns/feature_guardable.rb @@ -0,0 +1,23 @@ +# Simple feature guard that renders a 403 Forbidden status with a message +# when the feature is disabled. +# +# Example: +# +# class MessagesController < ApplicationController +# guard_feature unless: -> { Current.user.ai_enabled? } +# end +# +module FeatureGuardable + extend ActiveSupport::Concern + + class_methods do + def guard_feature(**options) + before_action :guard_feature, **options + end + end + + private + def guard_feature + render plain: "Feature disabled: #{controller_name}##{action_name}", status: :forbidden + end +end diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb new file mode 100644 index 00000000..7a7777be --- /dev/null +++ b/app/controllers/messages_controller.rb @@ -0,0 +1,24 @@ +class MessagesController < ApplicationController + guard_feature unless: -> { Current.user.ai_enabled? } + + before_action :set_chat + + def create + @message = UserMessage.create!( + chat: @chat, + content: message_params[:content], + ai_model: message_params[:ai_model] + ) + + redirect_to chat_path(@chat, thinking: true) + end + + private + def set_chat + @chat = Current.user.chats.find(params[:chat_id]) + end + + def message_params + params.require(:message).permit(:content, :ai_model) + end +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index e39e4975..c566d30e 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -10,7 +10,7 @@ class PagesController < ApplicationController end def changelog - @release_notes = Providers.github.fetch_latest_release_notes + @release_notes = github_provider.fetch_latest_release_notes render layout: "settings" end @@ -26,4 +26,9 @@ class PagesController < ApplicationController @invite_code = InviteCode.order("RANDOM()").limit(1).first render layout: false end + + private + def github_provider + Provider::Registry.get_provider(:github) + end end diff --git a/app/controllers/settings/hostings_controller.rb b/app/controllers/settings/hostings_controller.rb index f461fc20..6eea6ecc 100644 --- a/app/controllers/settings/hostings_controller.rb +++ b/app/controllers/settings/hostings_controller.rb @@ -1,11 +1,13 @@ class Settings::HostingsController < ApplicationController layout "settings" - before_action :raise_if_not_self_hosted + guard_feature unless: -> { self_hosted? } + before_action :ensure_admin, only: :clear_cache def show - @synth_usage = Providers.synth&.usage + synth_provider = Provider::Registry.get_provider(:synth) + @synth_usage = synth_provider&.usage end def update @@ -37,10 +39,6 @@ class Settings::HostingsController < ApplicationController params.require(:setting).permit(:require_invite_for_signup, :require_email_confirmation, :synth_api_key) end - def raise_if_not_self_hosted - raise "Settings not available on non-self-hosted instance" unless self_hosted? - end - def ensure_admin redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin? end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 4300477d..2b146864 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -17,11 +17,19 @@ class UsersController < ApplicationController redirect_to settings_profile_path, alert: error_message end else + was_ai_enabled = @user.ai_enabled @user.update!(user_params.except(:redirect_to, :delete_profile_image)) @user.profile_image.purge if should_purge_profile_image? + # Add a special notice if AI was just enabled + notice = if !was_ai_enabled && @user.ai_enabled + "AI Assistant has been enabled successfully." + else + t(".success") + end + respond_to do |format| - format.html { handle_redirect(t(".success")) } + format.html { handle_redirect(notice) } format.json { head :ok } end end @@ -66,7 +74,7 @@ class UsersController < ApplicationController def user_params params.require(:user).permit( - :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, + :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id, :data_enrichment_enabled ] ) end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e8d898cc..d131447f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -129,6 +129,73 @@ module ApplicationHelper cookies[:admin] == "true" end + # Renders Markdown text using Redcarpet + def markdown(text) + return "" if text.blank? + + renderer = Redcarpet::Render::HTML.new( + hard_wrap: true, + link_attributes: { target: "_blank", rel: "noopener noreferrer" } + ) + + markdown = Redcarpet::Markdown.new( + renderer, + autolink: true, + tables: true, + fenced_code_blocks: true, + strikethrough: true, + superscript: true, + underline: true, + highlight: true, + quote: true, + footnotes: true + ) + + markdown.render(text).html_safe + end + + # Determines the starting widths of each panel depending on the user's sidebar preferences + def app_sidebar_config(user) + left_sidebar_showing = user.show_sidebar? + right_sidebar_showing = user.show_ai_sidebar? + + content_max_width = if !left_sidebar_showing && !right_sidebar_showing + 1024 # 5xl + elsif left_sidebar_showing && !right_sidebar_showing + 896 # 4xl + else + 768 # 3xl + end + + left_panel_min_width = 320 + left_panel_max_width = 320 + right_panel_min_width = 400 + right_panel_max_width = 550 + + left_panel_width = left_sidebar_showing ? left_panel_min_width : 0 + right_panel_width = if right_sidebar_showing + left_sidebar_showing ? right_panel_min_width : right_panel_max_width + else + 0 + end + + { + left_panel: { + is_open: left_sidebar_showing, + initial_width: left_panel_width, + min_width: left_panel_min_width, + max_width: left_panel_max_width + }, + right_panel: { + is_open: right_sidebar_showing, + initial_width: right_panel_width, + min_width: right_panel_min_width, + max_width: right_panel_max_width + }, + content_max_width: content_max_width + } + end + private def calculate_total(item, money_method, negate) items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? } diff --git a/app/helpers/chats_helper.rb b/app/helpers/chats_helper.rb new file mode 100644 index 00000000..cb933a2e --- /dev/null +++ b/app/helpers/chats_helper.rb @@ -0,0 +1,12 @@ +module ChatsHelper + def chat_frame + :sidebar_chat + end + + def chat_view_path(chat) + return new_chat_path if params[:chat_view] == "new" + return chats_path if chat.nil? || params[:chat_view] == "all" + + chat.persisted? ? chat_path(chat) : new_chat_path + end +end diff --git a/app/helpers/menus_helper.rb b/app/helpers/menus_helper.rb index 55ad55a3..f903d8ec 100644 --- a/app/helpers/menus_helper.rb +++ b/app/helpers/menus_helper.rb @@ -1,13 +1,20 @@ module MenusHelper - def contextual_menu(&block) - tag.div data: { controller: "menu" } do - concat contextual_menu_icon + def contextual_menu(icon: "more-horizontal", id: nil, &block) + tag.div id: id, data: { controller: "menu" } do + concat contextual_menu_icon(icon) concat contextual_menu_content(&block) end end def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal) - link_to url, class: "flex items-center rounded-lg text-primary hover:bg-gray-50 py-2 px-3 gap-2", data: { turbo_frame: } do + link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do + concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary")) + concat(tag.span(label, class: "text-sm")) + end + end + + def contextual_menu_item(label, url:, icon:, turbo_frame: nil) + link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary")) concat(tag.span(label, class: "text-sm")) end @@ -16,7 +23,7 @@ module MenusHelper def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil) button_to url, method: :delete, - class: "flex items-center w-full rounded-lg text-red-500 hover:bg-red-500/5 py-2 px-3 gap-2", + class: "flex items-center w-full rounded-md text-red-500 hover:bg-red-500/5 p-2 gap-2", data: { turbo_confirm: turbo_confirm, turbo_frame: } do concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5")) concat(tag.span(label, class: "text-sm")) @@ -24,14 +31,14 @@ module MenusHelper end private - def contextual_menu_icon - tag.button class: "flex hover:bg-gray-100 p-2 rounded cursor-pointer", data: { menu_target: "button" } do - lucide_icon "more-horizontal", class: "w-5 h-5 text-secondary" + def contextual_menu_icon(icon) + tag.button class: "w-9 h-9 flex justify-center items-center hover:bg-surface-hover rounded-lg cursor-pointer focus:outline-none focus-visible:outline-none", data: { menu_target: "button" } do + lucide_icon icon, class: "w-5 h-5 text-secondary" end end def contextual_menu_content(&block) - tag.div class: "z-50 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden", + tag.div class: "min-w-[200px] p-1 z-50 shadow-border-xs bg-white rounded-lg hidden", data: { menu_target: "content" } do capture(&block) end diff --git a/app/javascript/controllers/chat_controller.js b/app/javascript/controllers/chat_controller.js new file mode 100644 index 00000000..7e067309 --- /dev/null +++ b/app/javascript/controllers/chat_controller.js @@ -0,0 +1,60 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["messages", "form", "input"]; + + connect() { + this.#configureAutoScroll(); + } + + disconnect() { + if (this.messagesObserver) { + this.messagesObserver.disconnect(); + } + } + + autoResize() { + const input = this.inputTarget; + const lineHeight = 20; // text-sm line-height (14px * 1.429 ≈ 20px) + const maxLines = 3; // 3 lines = 60px total + + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`; + input.style.overflowY = + input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden"; + } + + submitSampleQuestion(e) { + this.inputTarget.value = e.target.dataset.chatQuestionParam; + + setTimeout(() => { + this.formTarget.requestSubmit(); + }, 200); + } + + // Newlines require shift+enter, otherwise submit the form (same functionality as ChatGPT and others) + handleInputKeyDown(e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.formTarget.requestSubmit(); + } + } + + #configureAutoScroll() { + this.messagesObserver = new MutationObserver((_mutations) => { + if (this.hasMessagesTarget) { + this.#scrollToBottom(); + } + }); + + // Listen to entire sidebar for changes, always try to scroll to the bottom + this.messagesObserver.observe(this.element, { + childList: true, + subtree: true, + }); + } + + #scrollToBottom = () => { + this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight; + }; +} diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js index e0577edf..c5eb3a0c 100644 --- a/app/javascript/controllers/sidebar_controller.js +++ b/app/javascript/controllers/sidebar_controller.js @@ -2,17 +2,69 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="sidebar" export default class extends Controller { - static values = { userId: String }; - static targets = ["panel", "content"]; + static values = { + userId: String, + config: Object, + }; - toggle() { - this.panelTarget.classList.toggle("w-0"); - this.panelTarget.classList.toggle("opacity-0"); - this.panelTarget.classList.toggle("w-80"); - this.panelTarget.classList.toggle("opacity-100"); - this.contentTarget.classList.toggle("max-w-4xl"); - this.contentTarget.classList.toggle("max-w-5xl"); + static targets = ["leftPanel", "rightPanel", "content"]; + initialize() { + this.leftPanelOpen = this.configValue.left_panel.is_open; + this.rightPanelOpen = this.configValue.right_panel.is_open; + } + + toggleLeftPanel() { + this.leftPanelOpen = !this.leftPanelOpen; + this.#updatePanelWidths(); + this.#persistPreference("show_sidebar", this.leftPanelOpen); + } + + toggleRightPanel() { + this.rightPanelOpen = !this.rightPanelOpen; + this.#updatePanelWidths(); + this.#persistPreference("show_ai_sidebar", this.rightPanelOpen); + } + + #updatePanelWidths() { + this.contentTarget.style.maxWidth = `${this.#contentMaxWidth()}px`; + this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`; + this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`; + } + + #leftPanelWidth() { + if (this.leftPanelOpen) { + return this.configValue.left_panel.min_width; + } + + return 0; + } + + #rightPanelWidth() { + if (this.rightPanelOpen) { + if (this.leftPanelOpen) { + return this.configValue.right_panel.min_width; + } + + return this.configValue.right_panel.max_width; + } + + return 0; + } + + #contentMaxWidth() { + if (!this.leftPanelOpen && !this.rightPanelOpen) { + return 1024; + } + + if (this.leftPanelOpen && !this.rightPanelOpen) { + return 896; + } + + return 768; + } + + #persistPreference(field, value) { fetch(`/users/${this.userIdValue}`, { method: "PATCH", headers: { @@ -21,7 +73,7 @@ export default class extends Controller { Accept: "application/json", }, body: new URLSearchParams({ - "user[show_sidebar]": !this.panelTarget.classList.contains("w-0"), + [`user[${field}]`]: value, }).toString(), }); } diff --git a/app/jobs/assistant_response_job.rb b/app/jobs/assistant_response_job.rb new file mode 100644 index 00000000..66bc81bd --- /dev/null +++ b/app/jobs/assistant_response_job.rb @@ -0,0 +1,7 @@ +class AssistantResponseJob < ApplicationJob + queue_as :default + + def perform(message) + message.request_response + end +end diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index f251e7f1..bac6a50e 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -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 diff --git a/app/models/account/transaction/provided.rb b/app/models/account/transaction/provided.rb index 14df5b55..4bae0ab4 100644 --- a/app/models/account/transaction/provided.rb +++ b/app/models/account/transaction/provided.rb @@ -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 diff --git a/app/models/assistant.rb b/app/models/assistant.rb new file mode 100644 index 00000000..c1434a5e --- /dev/null +++ b/app/models/assistant.rb @@ -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 diff --git a/app/models/assistant/function.rb b/app/models/assistant/function.rb new file mode 100644 index 00000000..912063cc --- /dev/null +++ b/app/models/assistant/function.rb @@ -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 diff --git a/app/models/assistant/function/get_accounts.rb b/app/models/assistant/function/get_accounts.rb new file mode 100644 index 00000000..b912d81d --- /dev/null +++ b/app/models/assistant/function/get_accounts.rb @@ -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 diff --git a/app/models/assistant/function/get_balance_sheet.rb b/app/models/assistant/function/get_balance_sheet.rb new file mode 100644 index 00000000..81afeccb --- /dev/null +++ b/app/models/assistant/function/get_balance_sheet.rb @@ -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 diff --git a/app/models/assistant/function/get_income_statement.rb b/app/models/assistant/function/get_income_statement.rb new file mode 100644 index 00000000..ba3100a9 --- /dev/null +++ b/app/models/assistant/function/get_income_statement.rb @@ -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 diff --git a/app/models/assistant/function/get_transactions.rb b/app/models/assistant/function/get_transactions.rb new file mode 100644 index 00000000..6ca8faaa --- /dev/null +++ b/app/models/assistant/function/get_transactions.rb @@ -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 diff --git a/app/models/assistant/provided.rb b/app/models/assistant/provided.rb new file mode 100644 index 00000000..f33067c6 --- /dev/null +++ b/app/models/assistant/provided.rb @@ -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 diff --git a/app/models/assistant_message.rb b/app/models/assistant_message.rb new file mode 100644 index 00000000..67727040 --- /dev/null +++ b/app/models/assistant_message.rb @@ -0,0 +1,11 @@ +class AssistantMessage < Message + validates :ai_model, presence: true + + def role + "assistant" + end + + def broadcast? + true + end +end diff --git a/app/models/chat.rb b/app/models/chat.rb new file mode 100644 index 00000000..8ef81eaf --- /dev/null +++ b/app/models/chat.rb @@ -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 diff --git a/app/models/chat/debuggable.rb b/app/models/chat/debuggable.rb new file mode 100644 index 00000000..05bf8335 --- /dev/null +++ b/app/models/chat/debuggable.rb @@ -0,0 +1,7 @@ +module Chat::Debuggable + extend ActiveSupport::Concern + + def debug_mode? + ENV["AI_DEBUG_MODE"] == "true" + end +end diff --git a/app/models/developer_message.rb b/app/models/developer_message.rb new file mode 100644 index 00000000..ca1d2526 --- /dev/null +++ b/app/models/developer_message.rb @@ -0,0 +1,9 @@ +class DeveloperMessage < Message + def role + "developer" + end + + def broadcast? + chat.debug_mode? + end +end diff --git a/app/models/exchange_rate/provided.rb b/app/models/exchange_rate/provided.rb index 6c502c05..dbe87133 100644 --- a/app/models/exchange_rate/provided.rb +++ b/app/models/exchange_rate/provided.rb @@ -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]) diff --git a/app/models/family.rb b/app/models/family.rb index ec2d1bb6..0b4405e8 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 diff --git a/app/models/financial_assistant.rb b/app/models/financial_assistant.rb deleted file mode 100644 index 7480becc..00000000 --- a/app/models/financial_assistant.rb +++ /dev/null @@ -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 diff --git a/app/models/financial_assistant/provided.rb b/app/models/financial_assistant/provided.rb deleted file mode 100644 index f88ad339..00000000 --- a/app/models/financial_assistant/provided.rb +++ /dev/null @@ -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 diff --git a/app/models/message.rb b/app/models/message.rb new file mode 100644 index 00000000..c0a0b02e --- /dev/null +++ b/app/models/message.rb @@ -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 diff --git a/app/models/period.rb b/app/models/period.rb index 85ad2947..2cceb743 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -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 diff --git a/app/models/plaid_item/provided.rb b/app/models/plaid_item/provided.rb index 761a75c1..3d857e4b 100644 --- a/app/models/plaid_item/provided.rb +++ b/app/models/plaid_item/provided.rb @@ -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) diff --git a/app/models/provider.rb b/app/models/provider.rb index 6843475b..f188231e 100644 --- a/app/models/provider.rb +++ b/app/models/provider.rb @@ -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 diff --git a/app/models/exchange_rate/provideable.rb b/app/models/provider/exchange_rate_provider.rb similarity index 64% rename from app/models/exchange_rate/provideable.rb rename to app/models/provider/exchange_rate_provider.rb index 5f2278c6..b00ef2cc 100644 --- a/app/models/exchange_rate/provideable.rb +++ b/app/models/provider/exchange_rate_provider.rb @@ -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 diff --git a/app/models/provider/llm_provider.rb b/app/models/provider/llm_provider.rb new file mode 100644 index 00000000..8282975a --- /dev/null +++ b/app/models/provider/llm_provider.rb @@ -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 diff --git a/app/models/provider/openai.rb b/app/models/provider/openai.rb new file mode 100644 index 00000000..fb515fd7 --- /dev/null +++ b/app/models/provider/openai.rb @@ -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 diff --git a/app/models/provider/openai/chat_response_processor.rb b/app/models/provider/openai/chat_response_processor.rb new file mode 100644 index 00000000..c0d259ff --- /dev/null +++ b/app/models/provider/openai/chat_response_processor.rb @@ -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 diff --git a/app/models/provider/openai/chat_streamer.rb b/app/models/provider/openai/chat_streamer.rb new file mode 100644 index 00000000..598648d1 --- /dev/null +++ b/app/models/provider/openai/chat_streamer.rb @@ -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 diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb new file mode 100644 index 00000000..96548375 --- /dev/null +++ b/app/models/provider/registry.rb @@ -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 diff --git a/app/models/security/provideable.rb b/app/models/provider/security_provider.rb similarity index 69% rename from app/models/security/provideable.rb rename to app/models/provider/security_provider.rb index 2227e19f..63eba3de 100644 --- a/app/models/security/provideable.rb +++ b/app/models/provider/security_provider.rb @@ -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 diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 89850aa3..81d68ed5 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -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 diff --git a/app/models/providers.rb b/app/models/providers.rb deleted file mode 100644 index e0cd48ea..00000000 --- a/app/models/providers.rb +++ /dev/null @@ -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 diff --git a/app/models/security/provided.rb b/app/models/security/provided.rb index 4ef0f735..73d0435d 100644 --- a/app/models/security/provided.rb +++ b/app/models/security/provided.rb @@ -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 diff --git a/app/models/security/synth_combobox_option.rb b/app/models/security/synth_combobox_option.rb index 9c2336df..45091690 100644 --- a/app/models/security/synth_combobox_option.rb +++ b/app/models/security/synth_combobox_option.rb @@ -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 diff --git a/app/models/setting.rb b/app/models/setting.rb index da829d7e..5f44284a 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -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 diff --git a/app/models/tool_call.rb b/app/models/tool_call.rb new file mode 100644 index 00000000..2908fd13 --- /dev/null +++ b/app/models/tool_call.rb @@ -0,0 +1,3 @@ +class ToolCall < ApplicationRecord + belongs_to :message +end diff --git a/app/models/tool_call/function.rb b/app/models/tool_call/function.rb new file mode 100644 index 00000000..eb61afe1 --- /dev/null +++ b/app/models/tool_call/function.rb @@ -0,0 +1,4 @@ +class ToolCall::Function < ToolCall + validates :function_name, :function_result, presence: true + validates :function_arguments, presence: true, allow_blank: true +end diff --git a/app/models/user.rb b/app/models/user.rb index db929953..e997a58f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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) } diff --git a/app/models/user_message.rb b/app/models/user_message.rb new file mode 100644 index 00000000..1943758d --- /dev/null +++ b/app/models/user_message.rb @@ -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 diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb index 2acbf65f..764ca6af 100644 --- a/app/views/accounts/_account_sidebar_tabs.html.erb +++ b/app/views/accounts/_account_sidebar_tabs.html.erb @@ -1,6 +1,6 @@ <%# locals: (family:) %> -<% if family.requires_data_provider? && Providers.synth.nil? %> +<% if family.requires_data_provider? && Provider::Registry.get_provider(:synth).nil? %>
diff --git a/app/views/assistant_messages/_assistant_message.html.erb b/app/views/assistant_messages/_assistant_message.html.erb new file mode 100644 index 00000000..3aa193a2 --- /dev/null +++ b/app/views/assistant_messages/_assistant_message.html.erb @@ -0,0 +1,23 @@ +<%# locals: (assistant_message:) %> + +
+ <% if assistant_message.reasoning? %> +
+ +

Assistant reasoning

+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %> +
+ +
<%= markdown(assistant_message.content) %>
+
+ <% else %> + <% if assistant_message.chat.debug_mode? && assistant_message.tool_calls.any? %> + <%= render "assistant_messages/tool_calls", message: assistant_message %> + <% end %> + +
+ <%= render "chats/ai_avatar" %> +
<%= markdown(assistant_message.content) %>
+
+ <% end %> +
diff --git a/app/views/assistant_messages/_tool_calls.html.erb b/app/views/assistant_messages/_tool_calls.html.erb new file mode 100644 index 00000000..fc0e8129 --- /dev/null +++ b/app/views/assistant_messages/_tool_calls.html.erb @@ -0,0 +1,19 @@ +<%# locals: (message:) %> + +
+ + <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-secondary w-5" %> +

Tool Calls

+
+ +
+ <% message.tool_calls.each do |tool_call| %> +
+

Function:

+

<%= tool_call.function_name %>

+

Arguments:

+
<%= tool_call.function_arguments %>
+
+ <% end %> +
+
diff --git a/app/views/chats/_ai_avatar.html.erb b/app/views/chats/_ai_avatar.html.erb new file mode 100644 index 00000000..82cc8007 --- /dev/null +++ b/app/views/chats/_ai_avatar.html.erb @@ -0,0 +1,3 @@ +
+ <%= image_tag "ai.svg", alt: "AI", class: "w-full h-full" %> +
diff --git a/app/views/chats/_ai_consent.html.erb b/app/views/chats/_ai_consent.html.erb new file mode 100644 index 00000000..1fc2c722 --- /dev/null +++ b/app/views/chats/_ai_consent.html.erb @@ -0,0 +1,33 @@ +
+
+
+ <%= icon("sparkles") %> +
+ +

Enable Personal Finance AI

+ +

+ <% if Current.user.ai_available? %> + Our personal finance AI can help answer questions about your finances and provide insights based on your data. + To use this feature, you'll need to explicitly enable it. + <% else %> + To use the AI assistant, you need to set the OPENAI_ACCESS_TOKEN + environment variable in your self-hosted instance. + <% end %> +

+ + <% unless self_hosted? %> +

+ NOTE: During beta testing, we'll be monitoring usage and AI conversations to improve the assistant. +

+ <% end %> + + <% if Current.user.ai_available? %> + <%= form_with url: user_path(Current.user), method: :patch, class: "w-full", data: { turbo: false } do |form| %> + <%= form.hidden_field "user[ai_enabled]", value: true %> + <%= form.hidden_field "user[redirect_to]", value: "home" %> + <%= form.submit "Enable AI Assistant", class: "cursor-pointer hover:bg-black w-full py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %> + <% end %> + <% end %> +
+
diff --git a/app/views/chats/_ai_greeting.html.erb b/app/views/chats/_ai_greeting.html.erb new file mode 100644 index 00000000..688cfd3a --- /dev/null +++ b/app/views/chats/_ai_greeting.html.erb @@ -0,0 +1,40 @@ +
+ <%= render "chats/ai_avatar" %> + +
+

Hey <%= Current.user&.first_name || "there" %>! I'm an AI built by Maybe to help with your finances. I have access to the web and your account data.

+ +

+ You can use / to access commands +

+ +
+

Here's a few questions you can ask:

+ + <% questions = [ + { + icon: "bar-chart-2", + text: "Evaluate investment portfolio" + }, + { + icon: "credit-card", + text: "Show spending insights" + }, + { + icon: "alert-triangle", + text: "Find unusual patterns" + } + ] %> + +
+ <% questions.each do |question| %> + + <% end %> +
+
+
+
diff --git a/app/views/chats/_chat.html.erb b/app/views/chats/_chat.html.erb new file mode 100644 index 00000000..aa0a915b --- /dev/null +++ b/app/views/chats/_chat.html.erb @@ -0,0 +1,16 @@ +<%# locals: (chat:) %> + +<%= tag.div class: "flex items-center justify-between px-4 py-3 bg-container shadow-border-xs rounded-lg" do %> +
+ <%= render "chats/chat_title", chat: chat, ctx: "list" %> + +

+ <%= time_ago_in_words(chat.updated_at) %> ago +

+
+ + <%= contextual_menu icon: "more-vertical" do %> + <%= contextual_menu_item("Edit chat", url: edit_chat_path(chat), icon: "pencil", turbo_frame: dom_id(chat, :title)) %> + <%= contextual_menu_destructive_item("Delete chat", chat_path(chat)) %> + <% end %> +<% end %> diff --git a/app/views/chats/_chat_nav.html.erb b/app/views/chats/_chat_nav.html.erb new file mode 100644 index 00000000..5ef56402 --- /dev/null +++ b/app/views/chats/_chat_nav.html.erb @@ -0,0 +1,24 @@ +<%# locals: (chat:) %> + + diff --git a/app/views/chats/_chat_title.html.erb b/app/views/chats/_chat_title.html.erb new file mode 100644 index 00000000..8e6d59b8 --- /dev/null +++ b/app/views/chats/_chat_title.html.erb @@ -0,0 +1,11 @@ +<%# locals: (chat:, ctx: "list") %> + +<%= turbo_frame_tag dom_id(chat, :title), class: "block" do %> + <% if chat.new_record? || ctx == "chat" %> +

<%= chat.title || "New chat" %>

+ <% else %> + <%= link_to chat_path(chat), data: { turbo_frame: chat_frame } do %> +

<%= chat.title %>

+ <% end %> + <% end %> +<% end %> diff --git a/app/views/chats/_error.html.erb b/app/views/chats/_error.html.erb new file mode 100644 index 00000000..94fb2d2a --- /dev/null +++ b/app/views/chats/_error.html.erb @@ -0,0 +1,17 @@ +<%# locals: (chat:) %> + +
+ <% if chat.debug_mode? %> +
+ <%= chat.error %> +
+ <% end %> + +
+

Failed to generate response. Please try again.

+ + <%= button_to retry_chat_path(chat), method: :post, class: "btn btn--primary" do %> + Retry + <% end %> +
+
diff --git a/app/views/chats/_thinking_indicator.html.erb b/app/views/chats/_thinking_indicator.html.erb new file mode 100644 index 00000000..efd779b7 --- /dev/null +++ b/app/views/chats/_thinking_indicator.html.erb @@ -0,0 +1,6 @@ +<%# locals: (chat:, message: "Thinking ...") -%> + +
+ <%= render "chats/ai_avatar" %> +

<%= message %>

+
diff --git a/app/views/chats/edit.html.erb b/app/views/chats/edit.html.erb new file mode 100644 index 00000000..0c165ec5 --- /dev/null +++ b/app/views/chats/edit.html.erb @@ -0,0 +1,8 @@ +<%= turbo_frame_tag dom_id(@chat, :title), class: "block" do %> + <% bg_class = params[:ctx] == "chat" ? "bg-white" : "bg-container-inset" %> + <%= styled_form_with model: @chat, + class: class_names("p-1 rounded-md font-medium text-primary w-full", bg_class), + data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur" } do |f| %> + <%= f.text_field :title, data: { auto_submit_form_target: "auto" }, inline: true %> + <% end %> +<% end %> diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb new file mode 100644 index 00000000..bdb16b9d --- /dev/null +++ b/app/views/chats/index.html.erb @@ -0,0 +1,31 @@ +<%= turbo_frame_tag chat_frame do %> +
+ + +
+

Chats

+ + <% if @chats.any? %> +
+ <%= render @chats %> +
+ <% else %> +
+
+ <%= icon("message-square", size: "lg") %> +
+

No chats yet

+

Start a new conversation with the AI assistant

+ <%= link_to "Start a chat", new_chat_path, class: "inline-flex items-center gap-2 py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %> +
+ <% end %> +
+ + <%= render "messages/chat_form" %> +
+<% end %> diff --git a/app/views/chats/new.html.erb b/app/views/chats/new.html.erb new file mode 100644 index 00000000..4a00ceec --- /dev/null +++ b/app/views/chats/new.html.erb @@ -0,0 +1,11 @@ +<%= turbo_frame_tag chat_frame do %> +
+ <%= render "chats/chat_nav", chat: @chat %> + +
+ <%= render "chats/ai_greeting" %> +
+ + <%= render "messages/chat_form", chat: @chat %> +
+<% end %> diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb new file mode 100644 index 00000000..39461814 --- /dev/null +++ b/app/views/chats/show.html.erb @@ -0,0 +1,35 @@ +<%= turbo_frame_tag chat_frame do %> + <%= turbo_stream_from @chat %> + +

<%= @chat.title %>

+ +
+
+ <%= render "chats/chat_nav", chat: @chat %> +
+ +
+ <% if @chat.conversation_messages.any? %> + <% @chat.conversation_messages.ordered.each do |message| %> + <%= render message %> + <% end %> + <% else %> +
+ <%= render "chats/ai_greeting", context: "chat" %> +
+ <% end %> + + <% if params[:thinking].present? %> + <%= render "chats/thinking_indicator", chat: @chat %> + <% end %> + + <% if @chat.error.present? %> + <%= render "chats/error", chat: @chat %> + <% end %> +
+ +
+ <%= render "messages/chat_form", chat: @chat %> +
+
+<% end %> diff --git a/app/views/developer_messages/_developer_message.html.erb b/app/views/developer_messages/_developer_message.html.erb new file mode 100644 index 00000000..d756442f --- /dev/null +++ b/app/views/developer_messages/_developer_message.html.erb @@ -0,0 +1,6 @@ +<%# locals: (developer_message:) %> + +
px-3 py-2 rounded-lg max-w-[85%] ml-auto border"> + <%= developer_message.debug? ? "Debug message (internal only)" : "System instruction (sent to AI)" %> +

<%= developer_message.content %>

+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 50388e1e..4f9208ba 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,10 @@ <%= render "layouts/shared/htmldoc" do %> -
+ <% sidebar_config = app_sidebar_config(Current.user) %> + +
- <%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300", Current.user.show_sidebar? ? "w-80" : "w-0"), data: { sidebar_target: "panel" } do %> + <%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300"), + style: "width: #{sidebar_config.dig(:left_panel, :initial_width)}px", + data: { sidebar_target: "leftPanel" } do %> <% if content_for?(:sidebar) %> <%= yield :sidebar %> <% else %> @@ -43,7 +50,7 @@
<% end %> - <%= tag.div class: class_names("mx-auto w-full h-full", Current.user.show_sidebar? ? "max-w-4xl" : "max-w-5xl"), data: { sidebar_target: "content" } do %> + <%= tag.div style: "max-width: #{sidebar_config.dig(:content_max_width)}px", class: class_names("mx-auto w-full h-full"), data: { sidebar_target: "content" } do %> <% if content_for?(:breadcrumbs) %> <%= yield :breadcrumbs %> <% else %> @@ -57,5 +64,22 @@ <%= yield %> <% end %> <% end %> + + <%# AI chat sidebar %> + <%= tag.div id: "chat-container", + style: "width: #{sidebar_config.dig(:right_panel, :initial_width)}px", + class: class_names("flex flex-col justify-between shrink-0 transition-all duration-300"), + data: { controller: "chat hotkey", sidebar_target: "rightPanel", turbo_permanent: true } do %> + + <% if Current.user.ai_enabled? %> + <%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %> +
+ <%= lucide_icon("loader-circle", class: "w-5 h-5 text-secondary animate-spin") %> +
+ <% end %> + <% else %> + <%= render "chats/ai_consent" %> + <% end %> + <% end %>
<% end %> diff --git a/app/views/layouts/shared/_breadcrumbs.html.erb b/app/views/layouts/shared/_breadcrumbs.html.erb index 3ef70057..313147fa 100644 --- a/app/views/layouts/shared/_breadcrumbs.html.erb +++ b/app/views/layouts/shared/_breadcrumbs.html.erb @@ -2,7 +2,7 @@
+ + <% if sidebar_toggle_enabled %> +
+ +
+ <% end %> diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 5eb11807..c1be0c61 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -5,7 +5,6 @@ <%= csp_meta_tag %> <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %> <%= combobox_style_tag %> diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index bfeafd27..ce0a6300 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -5,7 +5,7 @@ <%= yield :head %> - +
<%= render_flash_notifications %> diff --git a/app/views/messages/_chat_form.html.erb b/app/views/messages/_chat_form.html.erb new file mode 100644 index 00000000..152309e0 --- /dev/null +++ b/app/views/messages/_chat_form.html.erb @@ -0,0 +1,35 @@ +<%# locals: (chat: nil, message_hint: nil) %> + +
+ <% model = chat && chat.persisted? ? [chat, Message.new] : Chat.new %> + + <%= form_with model: model, + class: "flex flex-col gap-2 bg-white px-2 py-1.5 rounded-lg shadow-border-xs", + data: { chat_target: "form" } do |f| %> + + <%# In the future, this will be a dropdown with different AI models %> + <%= f.hidden_field :ai_model, value: "gpt-4o" %> + + <%= f.text_area :content, placeholder: "Ask anything ...", value: message_hint, + class: "w-full border-0 focus:ring-0 text-sm resize-none px-1", + data: { chat_target: "input", action: "input->chat#autoResize keydown->chat#handleInputKeyDown" }, + rows: 1 %> + +
+
+ <%# These are disabled for now, but in the future, will all open specific menus with their own context and search %> + <% ["plus", "command", "at-sign", "mouse-pointer-click"].each do |icon| %> + + <% end %> +
+ + +
+ <% end %> + +

AI responses are informational only and are not financial advice.

+
diff --git a/app/views/securities/_combobox_security.turbo_stream.erb b/app/views/securities/_combobox_security.turbo_stream.erb index 3b6bc798..687d1858 100644 --- a/app/views/securities/_combobox_security.turbo_stream.erb +++ b/app/views/securities/_combobox_security.turbo_stream.erb @@ -5,7 +5,7 @@ <%= combobox_security.name.presence || combobox_security.symbol %> - <%= "#{combobox_security.symbol} (#{combobox_security.exchange_acronym.presence || combobox_security.exchange_operating_mic})" %> + <%= "#{combobox_security.symbol} (#{combobox_security.exchange_operating_mic})" %>
diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index f2487458..cbb77fb2 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -3,12 +3,10 @@
<%= contextual_menu do %> -
- <%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %> - <%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %> -
+ <%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %> + <%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %> + <%= contextual_menu_modal_action_item t(".edit_merchants"), merchants_path, icon: "store", turbo_frame: :_top %> + <%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %> <% end %> <%= link_to new_import_path, class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: "modal" } do %> diff --git a/app/views/user_messages/_user_message.html.erb b/app/views/user_messages/_user_message.html.erb new file mode 100644 index 00000000..cccf99ad --- /dev/null +++ b/app/views/user_messages/_user_message.html.erb @@ -0,0 +1,5 @@ +<%# locals: (user_message:) %> + +
+
<%= markdown(user_message.content) %>
+
diff --git a/bin/update_structure.sh b/bin/update_structure.sh new file mode 100755 index 00000000..1243067e --- /dev/null +++ b/bin/update_structure.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# save to .scripts/update_structure.sh +# best way to use is with tree: `brew install tree` + +# Create the output file with header +echo "---" > .cursor/rules/structure.mdc +echo "description: Project structure" >> .cursor/rules/structure.mdc +echo "globs: *" >> .cursor/rules/structure.mdc +echo "alwaysApply: true" >> .cursor/structure/structure.mdc +echo "---" >> .cursor/rules/structure.mdc +echo "" >> .cursor/rules/structure.mdc +echo "# Project Structure" > .cursor/rules/structure.mdc +echo "" >> .cursor/rules/structure.mdc +echo "\`\`\`" >> .cursor/rules/structure.mdc + +# Check if tree command is available +if command -v tree &> /dev/null; then + # Use tree command for better visualization + git ls-files --others --exclude-standard --cached | tree --fromfile -a >> .cursor/rules/structure.mdc + echo "Using tree command for structure visualization." +else + # Fallback to the alternative approach if tree is not available + echo "Tree command not found. Using fallback approach." + + # Get all files from git (respecting .gitignore) + git ls-files --others --exclude-standard --cached | sort > /tmp/files_list.txt + + # Create a simple tree structure + echo "." > /tmp/tree_items.txt + + # Process each file to build the tree + while read -r file; do + # Skip directories + if [[ -d "$file" ]]; then continue; fi + + # Add the file to the tree + echo "$file" >> /tmp/tree_items.txt + + # Add all parent directories + dir="$file" + while [[ "$dir" != "." ]]; do + dir=$(dirname "$dir") + echo "$dir" >> /tmp/tree_items.txt + done + done < /tmp/files_list.txt + + # Sort and remove duplicates + sort -u /tmp/tree_items.txt > /tmp/tree_sorted.txt + mv /tmp/tree_sorted.txt /tmp/tree_items.txt + + # Simple tree drawing approach + prev_dirs=() + + while read -r item; do + # Skip the root + if [[ "$item" == "." ]]; then + continue + fi + + # Determine if it's a file or directory + if [[ -f "$item" ]]; then + is_dir=0 + name=$(basename "$item") + else + is_dir=1 + name="$(basename "$item")/" + fi + + # Split path into components + IFS='/' read -ra path_parts <<< "$item" + + # Calculate depth (number of path components minus 1) + depth=$((${#path_parts[@]} - 1)) + + # Find common prefix with previous path + common=0 + if [[ ${#prev_dirs[@]} -gt 0 ]]; then + for ((i=0; i "$item" ]]; then + has_more=1 + break + fi + done + + if [[ $has_more -eq 1 ]]; then + prefix="${prefix}│ " + else + prefix="${prefix} " + fi + else + prefix="${prefix} " + fi + done + + # Determine if this is the last item in its directory + is_last=1 + dir=$(dirname "$item") + for next in $(grep "^$dir/" /tmp/tree_items.txt); do + if [[ "$next" > "$item" ]]; then + is_last=0 + break + fi + done + + # Choose the connector + if [[ $is_last -eq 1 ]]; then + connector="└── " + else + connector="├── " + fi + + # Output the item + echo "${prefix}${connector}${name}" >> .cursor/rules/structure.mdc + + # Save current path for next iteration + prev_dirs=("${path_parts[@]}") + + done < /tmp/tree_items.txt + + # Clean up + rm -f /tmp/files_list.txt /tmp/tree_items.txt +fi + +# Close the code block +echo "\`\`\`" >> .cursor/rules/structure.mdc + +echo "Project structure has been updated in .cursor/rules/structure.mdc" \ No newline at end of file diff --git a/config/initializers/intercom.rb b/config/initializers/intercom.rb index e9863abb..c87ae369 100644 --- a/config/initializers/intercom.rb +++ b/config/initializers/intercom.rb @@ -16,7 +16,7 @@ if ENV["INTERCOM_APP_ID"].present? && ENV["INTERCOM_IDENTITY_VERIFICATION_KEY"]. # == Enabled Environments # Which environments is auto inclusion of the Javascript enabled for # - config.enabled_environments = [ "development", "production" ] + config.enabled_environments = [ "production" ] # == Current user method/variable # The method/variable that contains the logged in user in your controllers. diff --git a/config/routes.rb b/config/routes.rb index 92845bc1..e8e072ae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,6 +11,15 @@ Rails.application.routes.draw do # Uses basic auth - see config/initializers/sidekiq.rb mount Sidekiq::Web => "/sidekiq" + # AI chats + resources :chats do + resources :messages, only: :create + + member do + post :retry + end + end + get "changelog", to: "pages#changelog" get "feedback", to: "pages#feedback" get "early-access", to: "pages#early_access" diff --git a/db/migrate/20250319212839_create_ai_chats.rb b/db/migrate/20250319212839_create_ai_chats.rb new file mode 100644 index 00000000..1a2bc9a5 --- /dev/null +++ b/db/migrate/20250319212839_create_ai_chats.rb @@ -0,0 +1,46 @@ +class CreateAiChats < ActiveRecord::Migration[7.2] + def change + create_table :chats, id: :uuid do |t| + t.references :user, null: false, foreign_key: true, type: :uuid + t.string :title, null: false + t.string :instructions + t.jsonb :error + t.string :latest_assistant_response_id + t.timestamps + end + + create_table :messages, id: :uuid do |t| + t.references :chat, null: false, foreign_key: true, type: :uuid + t.string :type, null: false + t.string :status, null: false, default: "complete" + t.text :content + t.string :ai_model + t.timestamps + + # Developer message fields + t.boolean :debug, default: false + + # Assistant message fields + t.string :provider_id + t.boolean :reasoning, default: false + end + + create_table :tool_calls, id: :uuid do |t| + t.references :message, null: false, foreign_key: true, type: :uuid + t.string :provider_id, null: false + t.string :provider_call_id + t.string :type, null: false + + # Function specific fields + t.string :function_name + t.jsonb :function_arguments + t.jsonb :function_result + + t.timestamps + end + + add_reference :users, :last_viewed_chat, foreign_key: { to_table: :chats }, null: true, type: :uuid + add_column :users, :show_ai_sidebar, :boolean, default: true + add_column :users, :ai_enabled, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 7f30fe84..04be6dc3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do +ActiveRecord::Schema[7.2].define(version: 2025_03_19_212839) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -196,6 +196,17 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do t.index ["family_id"], name: "index_categories_on_family_id" end + create_table "chats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id", null: false + t.string "title", null: false + t.string "instructions" + t.jsonb "error" + t.string "latest_assistant_response_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_chats_on_user_id" + end + create_table "credit_cards", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -380,6 +391,20 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do t.index ["family_id"], name: "index_merchants_on_family_id" end + create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "chat_id", null: false + t.string "type", null: false + t.string "status", default: "complete", null: false + t.text "content" + t.string "ai_model" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "debug", default: false + t.string "provider_id" + t.boolean "reasoning", default: false + t.index ["chat_id"], name: "index_messages_on_chat_id" + end + create_table "other_assets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -544,6 +569,19 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do t.index ["family_id"], name: "index_tags_on_family_id" end + create_table "tool_calls", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "message_id", null: false + t.string "provider_id", null: false + t.string "provider_call_id" + t.string "type", null: false + t.string "function_name" + t.jsonb "function_arguments" + t.jsonb "function_result" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["message_id"], name: "index_tool_calls_on_message_id" + end + create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "inflow_transaction_id", null: false t.uuid "outflow_transaction_id", null: false @@ -573,8 +611,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do t.string "otp_backup_codes", default: [], array: true t.boolean "show_sidebar", default: true t.string "default_period", default: "last_30_days", null: false + t.uuid "last_viewed_chat_id" + t.boolean "show_ai_sidebar", default: true + t.boolean "ai_enabled", default: false, null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" + t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id" t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)" end @@ -605,6 +647,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do add_foreign_key "budget_categories", "categories" add_foreign_key "budgets", "families" add_foreign_key "categories", "families" + add_foreign_key "chats", "users" add_foreign_key "impersonation_session_logs", "impersonation_sessions" add_foreign_key "impersonation_sessions", "users", column: "impersonated_id" add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" @@ -613,6 +656,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "merchants", "families" + add_foreign_key "messages", "chats" add_foreign_key "plaid_accounts", "plaid_items" add_foreign_key "plaid_items", "families" add_foreign_key "rejected_transfers", "account_transactions", column: "inflow_transaction_id" @@ -622,7 +666,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_19_145426) do add_foreign_key "sessions", "users" add_foreign_key "taggings", "tags" add_foreign_key "tags", "families" + add_foreign_key "tool_calls", "messages" add_foreign_key "transfers", "account_transactions", column: "inflow_transaction_id", on_delete: :cascade add_foreign_key "transfers", "account_transactions", column: "outflow_transaction_id", on_delete: :cascade + add_foreign_key "users", "chats", column: "last_viewed_chat_id" add_foreign_key "users", "families" end diff --git a/package-lock.json b/package-lock.json index 0e2cd5a5..3bdae723 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@biomejs/biome": "1.9.3" + "@biomejs/biome": "^1.9.3" } }, "node_modules/@biomejs/biome": { diff --git a/package.json b/package.json index 7ba6f216..1b0e7c43 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "devDependencies": { - "@biomejs/biome": "1.9.3" + "@biomejs/biome": "^1.9.3" }, "name": "maybe", "version": "1.0.0", "description": "The OS for your personal finances", "scripts": { "style:check": "biome check", - "style:fix":"biome check --write", - "lint": "biome lint", - "lint:fix" : "biome lint --write", - "format:check" : "biome format", - "format" : "biome format --write" + "style:fix": "biome check --write", + "lint": "biome lint", + "lint:fix": "biome lint --write", + "format:check": "biome format", + "format": "biome format --write" }, "author": "", "license": "ISC" diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 657e646c..a41ff371 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -21,6 +21,10 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase find("h1", text: "Welcome back, #{user.first_name}") end + def login_as(user) + sign_in(user) + end + def sign_out find("#user-menu").click click_button "Logout" diff --git a/test/controllers/chats_controller_test.rb b/test/controllers/chats_controller_test.rb new file mode 100644 index 00000000..5fa98e24 --- /dev/null +++ b/test/controllers/chats_controller_test.rb @@ -0,0 +1,52 @@ +require "test_helper" + +class ChatsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @family = families(:dylan_family) + sign_in @user + end + + test "cannot create a chat if AI is disabled" do + @user.update!(ai_enabled: false) + post chats_url, params: { chat: { content: "Hello", ai_model: "gpt-4o" } } + assert_response :forbidden + end + + test "gets index" do + get chats_url + assert_response :success + end + + test "creates chat" do + assert_difference("Chat.count") do + post chats_url, params: { chat: { content: "Hello", ai_model: "gpt-4o" } } + end + + assert_redirected_to chat_path(Chat.order(created_at: :desc).first, thinking: true) + end + + test "shows chat" do + get chat_url(chats(:one)) + assert_response :success + end + + test "destroys chat" do + assert_difference("Chat.count", -1) do + delete chat_url(chats(:one)) + end + + assert_redirected_to chats_url + end + + test "should not allow access to other user's chats" do + other_user = users(:family_member) + other_chat = Chat.create!(user: other_user, title: "Other User's Chat") + + get chat_url(other_chat) + assert_response :not_found + + delete chat_url(other_chat) + assert_response :not_found + end +end diff --git a/test/controllers/messages_controller_test.rb b/test/controllers/messages_controller_test.rb new file mode 100644 index 00000000..c79cb174 --- /dev/null +++ b/test/controllers/messages_controller_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class MessagesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @chat = @user.chats.first + end + + test "can create a message" do + post chat_messages_url(@chat), params: { message: { content: "Hello", ai_model: "gpt-4o" } } + + assert_redirected_to chat_path(@chat, thinking: true) + end + + test "cannot create a message if AI is disabled" do + @user.update!(ai_enabled: false) + + post chat_messages_url(@chat), params: { message: { content: "Hello", ai_model: "gpt-4o" } } + + assert_response :forbidden + end +end diff --git a/test/controllers/settings/hostings_controller_test.rb b/test/controllers/settings/hostings_controller_test.rb index 48213260..32bcaab2 100644 --- a/test/controllers/settings/hostings_controller_test.rb +++ b/test/controllers/settings/hostings_controller_test.rb @@ -8,7 +8,7 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest sign_in users(:family_admin) @provider = mock - Providers.stubs(:synth).returns(@provider) + Provider::Registry.stubs(:get_provider).with(:synth).returns(@provider) @usage_response = provider_success_response( OpenStruct.new( used: 10, @@ -20,12 +20,12 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest end test "cannot edit when self hosting is disabled" do - assert_raises(RuntimeError, "Settings not available on non-self-hosted instance") do + with_env_overrides SELF_HOSTED: "false" do get settings_hosting_url - end + assert_response :forbidden - assert_raises(RuntimeError, "Settings not available on non-self-hosted instance") do patch settings_hosting_url, params: { setting: { require_invite_for_signup: true } } + assert_response :forbidden end end @@ -40,8 +40,6 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest test "can update settings when self hosting is enabled" do with_self_hosting do - assert_nil Setting.synth_api_key - patch settings_hosting_url, params: { setting: { synth_api_key: "1234567890" } } assert_equal "1234567890", Setting.synth_api_key diff --git a/test/fixtures/chats.yml b/test/fixtures/chats.yml new file mode 100644 index 00000000..6e5c5d38 --- /dev/null +++ b/test/fixtures/chats.yml @@ -0,0 +1,7 @@ +one: + title: First Chat + user: family_admin + +two: + title: Second Chat + user: family_member \ No newline at end of file diff --git a/test/fixtures/messages.yml b/test/fixtures/messages.yml new file mode 100644 index 00000000..9a0e3ea5 --- /dev/null +++ b/test/fixtures/messages.yml @@ -0,0 +1,43 @@ +chat1_developer: + type: DeveloperMessage + content: You are a personal finance assistant. Be concise and helpful. + chat: one + created_at: 2025-03-20 12:00:00 + debug: false + +chat1_developer_debug: + type: DeveloperMessage + content: An internal debug message + chat: one + created_at: 2025-03-20 12:00:02 + debug: true + +chat1_user: + type: UserMessage + content: Can you help me understand my spending habits? + chat: one + ai_model: gpt-4o + created_at: 2025-03-20 12:00:01 + +chat2_user: + type: UserMessage + content: Can you help me understand my spending habits? + ai_model: gpt-4o + chat: two + created_at: 2025-03-20 12:00:01 + +chat1_assistant_reasoning: + type: AssistantMessage + content: I'm thinking... + ai_model: gpt-4o + chat: one + created_at: 2025-03-20 12:01:00 + reasoning: true + +chat1_assistant_response: + type: AssistantMessage + content: Hello! I can help you understand your spending habits. + ai_model: gpt-4o + chat: one + created_at: 2025-03-20 12:02:00 + reasoning: false diff --git a/test/fixtures/tool_calls.yml b/test/fixtures/tool_calls.yml new file mode 100644 index 00000000..470e00e6 --- /dev/null +++ b/test/fixtures/tool_calls.yml @@ -0,0 +1,7 @@ +one: + type: ToolCall::Function + function_name: get_user_info + provider_id: fc_12345xyz + provider_call_id: call_12345xyz + function_arguments: {} + message: chat1_assistant_response diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 246fdf56..ef3e7e3d 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -3,34 +3,38 @@ empty: first_name: User last_name: One email: user1@email.com - password_digest: <%= BCrypt::Password.create('password') %> + password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK onboarded_at: <%= 3.days.ago %> + ai_enabled: true maybe_support_staff: family: empty first_name: Support last_name: Admin email: support@maybefinance.com - password_digest: <%= BCrypt::Password.create('password') %> + password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK role: super_admin onboarded_at: <%= 3.days.ago %> + ai_enabled: true family_admin: family: dylan_family first_name: Bob last_name: Dylan email: bob@bobdylan.com - password_digest: <%= BCrypt::Password.create('password') %> + password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK role: admin onboarded_at: <%= 3.days.ago %> + ai_enabled: true family_member: family: dylan_family first_name: Jakob last_name: Dylan email: jakobdylan@yahoo.com - password_digest: <%= BCrypt::Password.create('password') %> + password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK onboarded_at: <%= 3.days.ago %> + ai_enabled: true new_email: family: empty @@ -38,5 +42,6 @@ new_email: last_name: User email: user@example.com unconfirmed_email: new@example.com - password_digest: <%= BCrypt::Password.create('password123') %> - onboarded_at: <%= Time.current %> \ No newline at end of file + password_digest: $2a$12$7p8hMsoc0zSaC8eY9oewzelHbmCPdpPi.mGiyG4vdZwrXmGpRPoNK + onboarded_at: <%= Time.current %> + ai_enabled: true \ No newline at end of file diff --git a/test/interfaces/exchange_rate_provider_interface_test.rb b/test/interfaces/exchange_rate_provider_interface_test.rb index 748c66f0..9293c4d9 100644 --- a/test/interfaces/exchange_rate_provider_interface_test.rb +++ b/test/interfaces/exchange_rate_provider_interface_test.rb @@ -11,11 +11,11 @@ module ExchangeRateProviderInterfaceTest date: Date.parse("01.01.2024") ) - rate = response.data.rate + rate = response.data - assert_kind_of ExchangeRate, rate - assert_equal "USD", rate.from_currency - assert_equal "GBP", rate.to_currency + assert_equal "USD", rate.from + assert_equal "GBP", rate.to + assert_in_delta 0.78, rate.rate, 0.01 end end @@ -25,7 +25,7 @@ module ExchangeRateProviderInterfaceTest from: "USD", to: "GBP", start_date: Date.parse("01.01.2024"), end_date: Date.parse("31.07.2024") ) - assert 213, response.data.rates.count # 213 days between 01.01.2024 and 31.07.2024 + assert_equal 213, response.data.count # 213 days between 01.01.2024 and 31.07.2024 end end diff --git a/test/interfaces/llm_interface_test.rb b/test/interfaces/llm_interface_test.rb new file mode 100644 index 00000000..2298ba27 --- /dev/null +++ b/test/interfaces/llm_interface_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +module LLMInterfaceTest + extend ActiveSupport::Testing::Declarative + + private + def vcr_key_prefix + @subject.class.name.demodulize.underscore + end +end diff --git a/test/interfaces/security_provider_interface_test.rb b/test/interfaces/security_provider_interface_test.rb index b22a6a10..44385ede 100644 --- a/test/interfaces/security_provider_interface_test.rb +++ b/test/interfaces/security_provider_interface_test.rb @@ -8,8 +8,9 @@ module SecurityProviderInterfaceTest VCR.use_cassette("#{vcr_key_prefix}/security_price") do response = @subject.fetch_security_price(aapl, date: Date.iso8601("2024-08-01")) + assert response.success? - assert response.data.price.present? + assert response.data.present? end end @@ -24,19 +25,18 @@ module SecurityProviderInterfaceTest ) assert response.success? - assert 213, response.data.prices.count + assert_equal 147, response.data.count # Synth won't return prices on weekends / holidays, so less than total day count of 213 end end test "searches securities" do VCR.use_cassette("#{vcr_key_prefix}/security_search") do response = @subject.search_securities("AAPL", country_code: "US") - securities = response.data.securities + securities = response.data assert securities.any? security = securities.first - assert_kind_of Security, security - assert_equal "AAPL", security.ticker + assert_equal "AAPL", security.symbol end end @@ -47,10 +47,10 @@ module SecurityProviderInterfaceTest response = @subject.fetch_security_info(aapl) info = response.data - assert_equal "AAPL", info.ticker + assert_equal "AAPL", info.symbol assert_equal "Apple Inc.", info.name - assert info.logo_url.present? assert_equal "common stock", info.kind + assert info.logo_url.present? assert info.description.present? end end diff --git a/test/jobs/enrich_data_job_test.rb b/test/jobs/enrich_data_job_test.rb deleted file mode 100644 index 067767f6..00000000 --- a/test/jobs/enrich_data_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class EnrichDataJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/revert_import_job_test.rb b/test/jobs/revert_import_job_test.rb deleted file mode 100644 index ca65d717..00000000 --- a/test/jobs/revert_import_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class RevertImportJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/jobs/user_purge_job_test.rb b/test/jobs/user_purge_job_test.rb deleted file mode 100644 index 9fb2ae63..00000000 --- a/test/jobs/user_purge_job_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class UserPurgeJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/account/convertible_test.rb b/test/models/account/convertible_test.rb index 8fb739c6..76cc5c85 100644 --- a/test/models/account/convertible_test.rb +++ b/test/models/account/convertible_test.rb @@ -24,13 +24,11 @@ class Account::ConvertibleTest < ActiveSupport::TestCase ExchangeRate.delete_all provider_response = provider_success_response( - ExchangeRate::Provideable::FetchRatesData.new( - rates: [ - ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: 2.days.ago.to_date, rate: 1.1), - ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: 1.day.ago.to_date, rate: 1.2), - ExchangeRate.new(from_currency: "EUR", to_currency: "USD", date: Date.current, rate: 1.3) - ] - ) + [ + OpenStruct.new(from: "EUR", to: "USD", date: 2.days.ago.to_date, rate: 1.1), + OpenStruct.new(from: "EUR", to: "USD", date: 1.day.ago.to_date, rate: 1.2), + OpenStruct.new(from: "EUR", to: "USD", date: Date.current, rate: 1.3) + ] ) @provider.expects(:fetch_exchange_rates) diff --git a/test/models/account/holding/portfolio_cache_test.rb b/test/models/account/holding/portfolio_cache_test.rb index bebc66c2..9b4124ed 100644 --- a/test/models/account/holding/portfolio_cache_test.rb +++ b/test/models/account/holding/portfolio_cache_test.rb @@ -82,12 +82,6 @@ class Account::Holding::PortfolioCacheTest < ActiveSupport::TestCase def expect_provider_prices(prices, start_date:, end_date: Date.current) @provider.expects(:fetch_security_prices) .with(@security, start_date: start_date, end_date: end_date) - .returns( - provider_success_response( - Security::Provideable::PricesData.new( - prices: prices - ) - ) - ) + .returns(provider_success_response(prices)) end end diff --git a/test/models/assistant_message_test.rb b/test/models/assistant_message_test.rb new file mode 100644 index 00000000..9e737d41 --- /dev/null +++ b/test/models/assistant_message_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class AssistantMessageTest < ActiveSupport::TestCase + setup do + @chat = chats(:one) + end + + test "broadcasts append after creation" do + message = AssistantMessage.create!(chat: @chat, content: "Hello from assistant", ai_model: "gpt-4o") + message.update!(content: "updated") + + streams = capture_turbo_stream_broadcasts(@chat) + assert_equal 2, streams.size + assert_equal "append", streams.first["action"] + assert_equal "messages", streams.first["target"] + assert_equal "update", streams.last["action"] + assert_equal "assistant_message_#{message.id}", streams.last["target"] + end +end diff --git a/test/models/assistant_test.rb b/test/models/assistant_test.rb new file mode 100644 index 00000000..56005482 --- /dev/null +++ b/test/models/assistant_test.rb @@ -0,0 +1,86 @@ +require "test_helper" +require "ostruct" + +class AssistantTest < ActiveSupport::TestCase + include ProviderTestHelper + + setup do + @chat = chats(:two) + @message = @chat.messages.create!( + type: "UserMessage", + content: "Help me with my finances", + ai_model: "gpt-4o" + ) + @assistant = Assistant.for_chat(@chat) + @provider = mock + @assistant.expects(:get_model_provider).with("gpt-4o").returns(@provider) + end + + test "responds to basic prompt" do + text_chunk = OpenStruct.new(type: "output_text", data: "Hello from assistant") + response_chunk = OpenStruct.new( + type: "response", + data: OpenStruct.new( + id: "1", + model: "gpt-4o", + messages: [ + OpenStruct.new( + id: "1", + content: "Hello from assistant", + ) + ], + functions: [] + ) + ) + + @provider.expects(:chat_response).with do |message, **options| + options[:streamer].call(text_chunk) + options[:streamer].call(response_chunk) + true + end + + assert_difference "AssistantMessage.count", 1 do + @assistant.respond_to(@message) + end + end + + test "responds with tool function calls" do + function_request_chunk = OpenStruct.new(type: "function_request", data: "get_net_worth") + text_chunk = OpenStruct.new(type: "output_text", data: "Your net worth is $124,200") + response_chunk = OpenStruct.new( + type: "response", + data: OpenStruct.new( + id: "1", + model: "gpt-4o", + messages: [ + OpenStruct.new( + id: "1", + content: "Your net worth is $124,200", + ) + ], + functions: [ + OpenStruct.new( + id: "1", + call_id: "1", + name: "get_net_worth", + arguments: "{}", + result: "$124,200" + ) + ] + ) + ) + + @provider.expects(:chat_response).with do |message, **options| + options[:streamer].call(function_request_chunk) + options[:streamer].call(text_chunk) + options[:streamer].call(response_chunk) + true + end + + assert_difference "AssistantMessage.count", 1 do + @assistant.respond_to(@message) + message = @chat.messages.ordered.where(type: "AssistantMessage").last + assert_equal 1, message.tool_calls.size + end + end +end diff --git a/test/models/chat_test.rb b/test/models/chat_test.rb new file mode 100644 index 00000000..29435b5a --- /dev/null +++ b/test/models/chat_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class ChatTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + @assistant = mock + end + + test "user sees all messages in debug mode" do + chat = chats(:one) + with_env_overrides AI_DEBUG_MODE: "true" do + assert_equal chat.messages.count, chat.conversation_messages.count + end + end + + test "user sees assistant and user messages in normal mode" do + chat = chats(:one) + assert_equal 3, chat.conversation_messages.count + end + + test "creates with initial message" do + prompt = "Test prompt" + + assert_difference "@user.chats.count", 1 do + chat = @user.chats.start!(prompt, model: "gpt-4o") + + assert_equal 1, chat.messages.count + assert_equal 1, chat.messages.where(type: "UserMessage").count + end + end +end diff --git a/test/models/developer_message_test.rb b/test/models/developer_message_test.rb new file mode 100644 index 00000000..26d3d8e2 --- /dev/null +++ b/test/models/developer_message_test.rb @@ -0,0 +1,28 @@ +require "test_helper" + +class DeveloperMessageTest < ActiveSupport::TestCase + setup do + @chat = chats(:one) + end + + test "does not broadcast" do + message = DeveloperMessage.create!(chat: @chat, content: "Some instructions") + message.update!(content: "updated") + + assert_no_turbo_stream_broadcasts(@chat) + end + + test "broadcasts if debug mode is enabled" do + with_env_overrides AI_DEBUG_MODE: "true" do + message = DeveloperMessage.create!(chat: @chat, content: "Some instructions") + message.update!(content: "updated") + + streams = capture_turbo_stream_broadcasts(@chat) + assert_equal 2, streams.size + assert_equal "append", streams.first["action"] + assert_equal "messages", streams.first["target"] + assert_equal "update", streams.last["action"] + assert_equal "developer_message_#{message.id}", streams.last["target"] + end + end +end diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb index 720162f4..64fc328b 100644 --- a/test/models/exchange_rate_test.rb +++ b/test/models/exchange_rate_test.rb @@ -26,13 +26,11 @@ class ExchangeRateTest < ActiveSupport::TestCase ExchangeRate.delete_all provider_response = provider_success_response( - ExchangeRate::Provideable::FetchRateData.new( - rate: ExchangeRate.new( - from_currency: "USD", - to_currency: "EUR", - date: Date.current, - rate: 1.2 - ) + OpenStruct.new( + from: "USD", + to: "EUR", + date: Date.current, + rate: 1.2 ) ) @@ -47,13 +45,11 @@ class ExchangeRateTest < ActiveSupport::TestCase ExchangeRate.delete_all provider_response = provider_success_response( - ExchangeRate::Provideable::FetchRateData.new( - rate: ExchangeRate.new( - from_currency: "USD", - to_currency: "EUR", - date: Date.current, - rate: 1.2 - ) + OpenStruct.new( + from: "USD", + to: "EUR", + date: Date.current, + rate: 1.2 ) ) @@ -65,7 +61,7 @@ class ExchangeRateTest < ActiveSupport::TestCase end test "returns nil on provider error" do - provider_response = provider_error_response(Provider::ProviderError.new("Test error")) + provider_response = provider_error_response(StandardError.new("Test error")) @provider.expects(:fetch_exchange_rate).returns(provider_response) @@ -77,15 +73,11 @@ class ExchangeRateTest < ActiveSupport::TestCase ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "USD", to_currency: "EUR", rate: 0.9) - provider_response = provider_success_response( - ExchangeRate::Provideable::FetchRatesData.new( - rates: [ - ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: Date.current, rate: 1.3), - ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: 1.day.ago.to_date, rate: 1.4), - ExchangeRate.new(from_currency: "USD", to_currency: "EUR", date: 2.days.ago.to_date, rate: 1.5) - ] - ) - ) + provider_response = provider_success_response([ + OpenStruct.new(from: "USD", to: "EUR", date: Date.current, rate: 1.3), + OpenStruct.new(from: "USD", to: "EUR", date: 1.day.ago.to_date, rate: 1.4), + OpenStruct.new(from: "USD", to: "EUR", date: 2.days.ago.to_date, rate: 1.5) + ]) @provider.expects(:fetch_exchange_rates) .with(from: "USD", to: "EUR", start_date: 2.days.ago.to_date, end_date: Date.current) diff --git a/test/models/provider/openai_test.rb b/test/models/provider/openai_test.rb new file mode 100644 index 00000000..ccaae937 --- /dev/null +++ b/test/models/provider/openai_test.rb @@ -0,0 +1,136 @@ +require "test_helper" + +class Provider::OpenaiTest < ActiveSupport::TestCase + include LLMInterfaceTest + + setup do + @subject = @openai = Provider::Openai.new(ENV.fetch("OPENAI_ACCESS_TOKEN", "test-openai-token")) + @subject_model = "gpt-4o" + @chat = chats(:two) + end + + test "openai errors are automatically raised" do + VCR.use_cassette("openai/chat/error") do + response = @openai.chat_response(UserMessage.new( + chat: @chat, + content: "Error test", + ai_model: "invalid-model-that-will-trigger-api-error" + )) + + assert_not response.success? + assert_kind_of Provider::Openai::Error, response.error + end + end + + test "basic chat response" do + VCR.use_cassette("openai/chat/basic_response") do + message = @chat.messages.create!( + type: "UserMessage", + content: "This is a chat test. If it's working, respond with a single word: Yes", + ai_model: @subject_model + ) + + response = @subject.chat_response(message) + + assert response.success? + assert_equal 1, response.data.messages.size + assert_includes response.data.messages.first.content, "Yes" + end + end + + test "streams basic chat response" do + VCR.use_cassette("openai/chat/basic_response") do + collected_chunks = [] + + mock_streamer = proc do |chunk| + collected_chunks << chunk + end + + message = @chat.messages.create!( + type: "UserMessage", + content: "This is a chat test. If it's working, respond with a single word: Yes", + ai_model: @subject_model + ) + + @subject.chat_response(message, streamer: mock_streamer) + + tool_call_chunks = collected_chunks.select { |chunk| chunk.type == "function_request" } + text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" } + response_chunks = collected_chunks.select { |chunk| chunk.type == "response" } + + assert_equal 1, text_chunks.size + assert_equal 1, response_chunks.size + assert_equal 0, tool_call_chunks.size + assert_equal "Yes", text_chunks.first.data + assert_equal "Yes", response_chunks.first.data.messages.first.content + end + end + + test "chat response with tool calls" do + VCR.use_cassette("openai/chat/tool_calls") do + response = @subject.chat_response( + tool_call_message, + instructions: "Use the tools available to you to answer the user's question.", + available_functions: [ PredictableToolFunction.new(@chat) ] + ) + + assert response.success? + assert_equal 1, response.data.functions.size + assert_equal 1, response.data.messages.size + assert_includes response.data.messages.first.content, PredictableToolFunction.expected_test_result + end + end + + test "streams chat response with tool calls" do + VCR.use_cassette("openai/chat/tool_calls") do + collected_chunks = [] + + mock_streamer = proc do |chunk| + collected_chunks << chunk + end + + @subject.chat_response( + tool_call_message, + instructions: "Use the tools available to you to answer the user's question.", + available_functions: [ PredictableToolFunction.new(@chat) ], + streamer: mock_streamer + ) + + text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" } + text_chunks = collected_chunks.select { |chunk| chunk.type == "output_text" } + tool_call_chunks = collected_chunks.select { |chunk| chunk.type == "function_request" } + response_chunks = collected_chunks.select { |chunk| chunk.type == "response" } + + assert_equal 1, tool_call_chunks.count + assert text_chunks.count >= 1 + assert_equal 1, response_chunks.count + + assert_includes response_chunks.first.data.messages.first.content, PredictableToolFunction.expected_test_result + end + end + + private + def tool_call_message + UserMessage.new(chat: @chat, content: "What is my net worth?", ai_model: @subject_model) + end + + class PredictableToolFunction < Assistant::Function + class << self + def expected_test_result + "$124,200" + end + + def name + "get_net_worth" + end + + def description + "Gets user net worth data" + end + end + + def call(params = {}) + self.class.expected_test_result + end + end +end diff --git a/test/models/providers_test.rb b/test/models/provider/registry_test.rb similarity index 62% rename from test/models/providers_test.rb rename to test/models/provider/registry_test.rb index d7851cd8..76c20cd7 100644 --- a/test/models/providers_test.rb +++ b/test/models/provider/registry_test.rb @@ -1,11 +1,11 @@ require "test_helper" -class ProvidersTest < ActiveSupport::TestCase +class Provider::RegistryTest < ActiveSupport::TestCase test "synth configured with ENV" do Setting.stubs(:synth_api_key).returns(nil) with_env_overrides SYNTH_API_KEY: "123" do - assert_instance_of Provider::Synth, Providers.synth + assert_instance_of Provider::Synth, Provider::Registry.get_provider(:synth) end end @@ -13,7 +13,7 @@ class ProvidersTest < ActiveSupport::TestCase Setting.stubs(:synth_api_key).returns("123") with_env_overrides SYNTH_API_KEY: nil do - assert_instance_of Provider::Synth, Providers.synth + assert_instance_of Provider::Synth, Provider::Registry.get_provider(:synth) end end @@ -21,7 +21,7 @@ class ProvidersTest < ActiveSupport::TestCase Setting.stubs(:synth_api_key).returns(nil) with_env_overrides SYNTH_API_KEY: nil do - assert_nil Providers.synth + assert_nil Provider::Registry.get_provider(:synth) end end end diff --git a/test/models/provider_test.rb b/test/models/provider_test.rb index afa770e4..5b9a9287 100644 --- a/test/models/provider_test.rb +++ b/test/models/provider_test.rb @@ -3,7 +3,7 @@ require "ostruct" class TestProvider < Provider def fetch_data - provider_response(retries: 3) do + with_provider_response(retries: 3) do client.get("/test") end end @@ -51,7 +51,7 @@ class ProviderTest < ActiveSupport::TestCase client.expects(:get) .with("/test") - .returns(Provider::ProviderResponse.new(success?: true, data: "success", error: nil)) + .returns(Provider::Response.new(success?: true, data: "success", error: nil)) .in_sequence(sequence) response = @provider.fetch_data diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb index 84412c29..bd150359 100644 --- a/test/models/security/price_test.rb +++ b/test/models/security/price_test.rb @@ -40,11 +40,11 @@ class Security::PriceTest < ActiveSupport::TestCase security = securities(:aapl) Security::Price.delete_all # Clear any existing prices - provider_response = provider_error_response(Provider::ProviderError.new("Test error")) + with_provider_response = provider_error_response(StandardError.new("Test error")) @provider.expects(:fetch_security_price) .with(security, date: Date.current) - .returns(provider_response) + .returns(with_provider_response) assert_not @security.find_or_fetch_price(date: Date.current) end @@ -72,12 +72,12 @@ class Security::PriceTest < ActiveSupport::TestCase def expect_provider_price(security:, price:, date:) @provider.expects(:fetch_security_price) .with(security, date: date) - .returns(provider_success_response(Security::Provideable::PriceData.new(price: price))) + .returns(provider_success_response(price)) end def expect_provider_prices(security:, prices:, start_date:, end_date:) @provider.expects(:fetch_security_prices) .with(security, start_date: start_date, end_date: end_date) - .returns(provider_success_response(Security::Provideable::PricesData.new(prices: prices))) + .returns(provider_success_response(prices)) end end diff --git a/test/models/user_message_test.rb b/test/models/user_message_test.rb new file mode 100644 index 00000000..32aff7d4 --- /dev/null +++ b/test/models/user_message_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class UserMessageTest < ActiveSupport::TestCase + setup do + @chat = chats(:one) + end + + test "requests assistant response after creation" do + @chat.expects(:ask_assistant_later).once + + message = UserMessage.create!(chat: @chat, content: "Hello from user", ai_model: "gpt-4o") + message.update!(content: "updated") + + streams = capture_turbo_stream_broadcasts(@chat) + assert_equal 2, streams.size + assert_equal "append", streams.first["action"] + assert_equal "messages", streams.first["target"] + assert_equal "update", streams.last["action"] + assert_equal "user_message_#{message.id}", streams.last["target"] + end +end diff --git a/test/support/provider_test_helper.rb b/test/support/provider_test_helper.rb index 45c7de2b..3e3ddcea 100644 --- a/test/support/provider_test_helper.rb +++ b/test/support/provider_test_helper.rb @@ -1,6 +1,6 @@ module ProviderTestHelper def provider_success_response(data) - Provider::ProviderResponse.new( + Provider::Response.new( success?: true, data: data, error: nil @@ -8,7 +8,7 @@ module ProviderTestHelper end def provider_error_response(error) - Provider::ProviderResponse.new( + Provider::Response.new( success?: false, data: nil, error: error diff --git a/test/system/chats_test.rb b/test/system/chats_test.rb new file mode 100644 index 00000000..6e1fe5b5 --- /dev/null +++ b/test/system/chats_test.rb @@ -0,0 +1,66 @@ +require "application_system_test_case" + +class ChatsTest < ApplicationSystemTestCase + setup do + @user = users(:family_admin) + login_as(@user) + end + + test "sidebar shows consent if ai is disabled for user" do + @user.update!(ai_enabled: false) + + visit root_path + + within "#chat-container" do + assert_selector "h3", text: "Enable Personal Finance AI" + end + end + + test "sidebar shows index when enabled and chats are empty" do + @user.update!(ai_enabled: true) + @user.chats.destroy_all + + visit root_url + + within "#chat-container" do + assert_selector "h1", text: "Chats" + end + end + + test "sidebar shows last viewed chat" do + @user.update!(ai_enabled: true) + + click_on @user.chats.first.title + + # Page refresh + visit root_url + + # After page refresh, we're still on the last chat we were viewing + within "#chat-container" do + assert_selector "h1", text: @user.chats.first.title + end + end + + test "create chat and navigate chats sidebar" do + @user.chats.destroy_all + + visit root_url + + Chat.any_instance.expects(:ask_assistant_later).once + + within "#chat-form" do + fill_in "chat[content]", with: "Can you help with my finances?" + find("button[type='submit']").click + end + + assert_text "Can you help with my finances?" + + find("#chat-nav-back").click + + assert_selector "h1", text: "Chats" + + click_on @user.chats.reload.first.title + + assert_text "Can you help with my finances?" + end +end diff --git a/test/system/settings_test.rb b/test/system/settings_test.rb index dbc8ca99..0fe32c6b 100644 --- a/test/system/settings_test.rb +++ b/test/system/settings_test.rb @@ -33,7 +33,7 @@ class SettingsTest < ApplicationSystemTestCase test "can update self hosting settings" do Rails.application.config.app_mode.stubs(:self_hosted?).returns(true) - Providers.stubs(:synth).returns(nil) + Provider::Registry.stubs(:get_provider).with(:synth).returns(nil) open_settings_from_sidebar assert_selector "li", text: "Self hosting" click_link "Self hosting" diff --git a/test/test_helper.rb b/test/test_helper.rb index 6b386535..9e1bb2c9 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -24,6 +24,8 @@ VCR.configure do |config| config.ignore_localhost = true config.default_cassette_options = { erb: true } config.filter_sensitive_data("") { ENV["SYNTH_API_KEY"] } + config.filter_sensitive_data("") { ENV["OPENAI_ACCESS_TOKEN"] } + config.filter_sensitive_data("") { ENV["OPENAI_ORGANIZATION_ID"] } end module ActiveSupport diff --git a/test/vcr_cassettes/openai/chat/basic_response.yml b/test/vcr_cassettes/openai/chat/basic_response.yml new file mode 100644 index 00000000..2975b37d --- /dev/null +++ b/test/vcr_cassettes/openai/chat/basic_response.yml @@ -0,0 +1,92 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4o","input":[{"role":"user","content":"This is a chat + test. If it''s working, respond with a single word: Yes"}],"instructions":null,"tools":[],"previous_response_id":null,"stream":true}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 26 Mar 2025 21:27:38 GMT + Content-Type: + - text/event-stream; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_8fce503a4c5be145dda20867925b1622 + Openai-Processing-Ms: + - '103' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=o5kysxtwKJs3TPoOquM0X4MkyLIaylWhRd8LhagxXck-1743024458-1.0.1.1-ol6ndVCx6dHLGnc9.YmKYwgfOBqhSZSBpIHg4STCi4OBhrgt70FYPmMptrYDvg.SoFuS5RAS_pGiNNWXHspHio3gTfJ87vIdT936GYHIDrc; + path=/; expires=Wed, 26-Mar-25 21:57:38 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=Iqk8pY6uwz2lLhdKt0PwWTdtYQUqqvS6xmP9DMVko2A-1743024458829-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9269bbb21b1ecf43-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: |+ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67e4714ab0148192ae2cc4303794d6fc0c1a792abcdc2819","object":"response","created_at":1743024458,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67e4714ab0148192ae2cc4303794d6fc0c1a792abcdc2819","object":"response","created_at":1743024458,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"delta":"Yes"} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"text":"Yes"} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Yes","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Yes","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67e4714ab0148192ae2cc4303794d6fc0c1a792abcdc2819","object":"response","created_at":1743024458,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[{"type":"message","id":"msg_67e4714b1f8c8192b9b16febe8be86550c1a792abcdc2819","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Yes","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":43,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":45},"user":null,"metadata":{}}} + + recorded_at: Wed, 26 Mar 2025 21:27:39 GMT +recorded_with: VCR 6.3.1 +... diff --git a/test/vcr_cassettes/openai/chat/error.yml b/test/vcr_cassettes/openai/chat/error.yml new file mode 100644 index 00000000..cdae2b37 --- /dev/null +++ b/test/vcr_cassettes/openai/chat/error.yml @@ -0,0 +1,72 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"invalid-model-that-will-trigger-api-error","input":[{"role":"user","content":"Error + test"}],"instructions":null,"tools":[],"previous_response_id":null,"stream":true}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 400 + message: Bad Request + headers: + Date: + - Wed, 26 Mar 2025 21:27:19 GMT + Content-Type: + - application/json + Content-Length: + - '207' + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_2b86e02f664e790dfa475f111402b722 + Openai-Processing-Ms: + - '146' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=gAU0gS_ZQBfQmFkc_jKM73dhkNISbBY9FlQjGnZ6CfU-1743024439-1.0.1.1-bWRoC737.SOJPZrP90wTJLVmelTpxFqIsrunq2Lqgy4J3VvLtYBEBrqY0v4d94F5fMcm0Ju.TfQi0etmvqZtUSMRn6rvkMLmXexRcxP.1jE; + path=/; expires=Wed, 26-Mar-25 21:57:19 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=XnxX4KU80himuKAUavZYtkQasOjXJDJD.QLyMrfBSUU-1743024439792-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9269bb3b2c14cf74-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: |- + { + "error": { + "message": "The requested model 'invalid-model-that-will-trigger-api-error' does not exist.", + "type": "invalid_request_error", + "param": "model", + "code": "model_not_found" + } + } + recorded_at: Wed, 26 Mar 2025 21:27:19 GMT +recorded_with: VCR 6.3.1 diff --git a/test/vcr_cassettes/openai/chat/tool_calls.yml b/test/vcr_cassettes/openai/chat/tool_calls.yml new file mode 100644 index 00000000..0135aff5 --- /dev/null +++ b/test/vcr_cassettes/openai/chat/tool_calls.yml @@ -0,0 +1,201 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net + worth?"}],"instructions":"Use the tools available to you to answer the user''s + question.","tools":[{"type":"function","name":"get_net_worth","description":"Gets + user net worth data","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"previous_response_id":null,"stream":true}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 26 Mar 2025 21:22:09 GMT + Content-Type: + - text/event-stream; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_4f04cffbab6051b3ac301038e3796092 + Openai-Processing-Ms: + - '114' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=F5haUlL1HA1srjwZugBxG6XWbGg.NyQBnJTTirKs5KI-1743024129-1.0.1.1-D842I3sPgDgH_KXyroq6uVivEnbWvm9WJF.L8a11GgUcULXjhweLHs0mXe6MWruf.FJe.lZj.KmX0tCqqdpKIt5JvlbHXt5D_9svedktlZY; + path=/; expires=Wed, 26-Mar-25 21:52:09 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=MmuRzsy8ebDMe6ibCEwtGp2RzcntpAmdvDlhIZtlY1s-1743024129721-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9269b3a97f370002-ORD + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: |+ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","object":"response","created_at":1743024129,"status":"in_progress","error":null,"incomplete_details":null,"instructions":"Use the tools available to you to answer the user's question.","max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"function","description":"Gets user net worth data","name":"get_net_worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","object":"response","created_at":1743024129,"status":"in_progress","error":null,"incomplete_details":null,"instructions":"Use the tools available to you to answer the user's question.","max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"function","description":"Gets user net worth data","name":"get_net_worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","name":"get_net_worth","arguments":"","status":"in_progress"}} + + event: response.function_call_arguments.delta + data: {"type":"response.function_call_arguments.delta","item_id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","output_index":0,"delta":"{}"} + + event: response.function_call_arguments.done + data: {"type":"response.function_call_arguments.done","item_id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","output_index":0,"arguments":"{}"} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","name":"get_net_worth","arguments":"{}","status":"completed"}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","object":"response","created_at":1743024129,"status":"completed","error":null,"incomplete_details":null,"instructions":"Use the tools available to you to answer the user's question.","max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[{"type":"function_call","id":"fc_67e4700222008192b3a26ce30fe7ad02069d9116026394b6","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","name":"get_net_worth","arguments":"{}","status":"completed"}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[{"type":"function","description":"Gets user net worth data","name":"get_net_worth","parameters":{"type":"object","properties":{},"required":[],"additionalProperties":false},"strict":true}],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":271,"input_tokens_details":{"cached_tokens":0},"output_tokens":13,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":284},"user":null,"metadata":{}}} + + recorded_at: Wed, 26 Mar 2025 21:22:10 GMT +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"gpt-4o","input":[{"role":"user","content":"What is my net + worth?"},{"type":"function_call_output","call_id":"call_FtvrJsTMg7he0mTeThIqktyL","output":"\"$124,200\""}],"instructions":"Use + the tools available to you to answer the user''s question.","tools":[],"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","stream":true}' + headers: + Content-Type: + - application/json + Authorization: + - Bearer + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 26 Mar 2025 21:22:10 GMT + Content-Type: + - text/event-stream; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + X-Request-Id: + - req_792bf572fac53f7e139b29d462933d8f + Openai-Processing-Ms: + - '148' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - __cf_bm=HHguTnSUQFt9KezJAQCrQF_OHn8ZH1C4xDjXRgexdzM-1743024130-1.0.1.1-ZhqxuASVfISfGQbvvKSNy_OQiUfkeIPN2DZhors0K4cl_BzE_P5u9kbc1HkgwyW1A_6GNAenh8Fr9AkoJ0zSakdg5Dr9AU.lu5nr7adQ_60; + path=/; expires=Wed, 26-Mar-25 21:52:10 GMT; domain=.api.openai.com; HttpOnly; + Secure; SameSite=None + - _cfuvid=hX9Y33ruiC9mhYzrOoxyOh23Gy.MfQa54h9l5CllWlI-1743024130948-0.0.1.1-604800000; + path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - 9269b3b0da83cf67-CMH + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: UTF-8 + string: |+ + event: response.created + data: {"type":"response.created","response":{"id":"resp_67e47002c5b48192a8202d45c6a929f8069d9116026394b6","object":"response","created_at":1743024130,"status":"in_progress","error":null,"incomplete_details":null,"instructions":"Use the tools available to you to answer the user's question.","max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_67e47002c5b48192a8202d45c6a929f8069d9116026394b6","object":"response","created_at":1743024130,"status":"in_progress","error":null,"incomplete_details":null,"instructions":"Use the tools available to you to answer the user's question.","max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} + + event: response.output_item.added + data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","status":"in_progress","role":"assistant","content":[]}} + + event: response.content_part.added + data: {"type":"response.content_part.added","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"Your"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" net"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" worth"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" is"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":" $"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"124"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":","} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"200"} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"delta":"."} + + event: response.output_text.done + data: {"type":"response.output_text.done","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"text":"Your net worth is $124,200."} + + event: response.content_part.done + data: {"type":"response.content_part.done","item_id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Your net worth is $124,200.","annotations":[]}} + + event: response.output_item.done + data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Your net worth is $124,200.","annotations":[]}]}} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_67e47002c5b48192a8202d45c6a929f8069d9116026394b6","object":"response","created_at":1743024130,"status":"completed","error":null,"incomplete_details":null,"instructions":"Use the tools available to you to answer the user's question.","max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[{"type":"message","id":"msg_67e47003483c819290ae392b826c4910069d9116026394b6","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Your net worth is $124,200.","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":"resp_67e4700196288192b27a4effc08dc47f069d9116026394b6","reasoning":{"effort":null,"generate_summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":85,"input_tokens_details":{"cached_tokens":0},"output_tokens":10,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":95},"user":null,"metadata":{}}} + + recorded_at: Wed, 26 Mar 2025 21:22:11 GMT +recorded_with: VCR 6.3.1 +...