mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 19:44:09 +00:00
* Add SearchFamilyImportedFiles assistant function with vector store support Implement per-Family document search using OpenAI vector stores, allowing the AI assistant to search through uploaded financial documents (tax returns, statements, contracts, etc.). The architecture is modular with a provider- agnostic VectorStoreConcept interface so other RAG backends can be added. Key components: - Assistant::Function::SearchFamilyImportedFiles - tool callable from any LLM - Provider::VectorStoreConcept - abstract vector store interface - Provider::Openai vector store methods (create, upload, search, delete) - Family::VectorSearchable concern with document management - FamilyDocument model for tracking uploaded files - Migration adding vector_store_id to families and family_documents table https://claude.ai/code/session_01TSkKc7a9Yu2ugm1RvSf4dh * Extract VectorStore adapter layer for swappable backends Replace the Provider::VectorStoreConcept mixin with a standalone adapter architecture under VectorStore::. This cleanly separates vector store concerns from the LLM provider and makes it trivial to swap backends. Components: - VectorStore::Base — abstract interface (create/delete/upload/remove/search) - VectorStore::Openai — uses ruby-openai gem's native vector_stores.search - VectorStore::Pgvector — skeleton for local pgvector + embedding model - VectorStore::Qdrant — skeleton for Qdrant vector DB - VectorStore::Registry — resolves adapter from VECTOR_STORE_PROVIDER env - VectorStore::Response — success/failure wrapper (like Provider::Response) Consumers updated to go through VectorStore.adapter: - Family::VectorSearchable - Assistant::Function::SearchFamilyImportedFiles - FamilyDocument Removed: Provider::VectorStoreConcept, vector store methods from Provider::Openai https://claude.ai/code/session_01TSkKc7a9Yu2ugm1RvSf4dh * Add Vector Store configuration docs to ai.md Documents how to configure the document search feature, covering all three supported backends (OpenAI, pgvector, Qdrant), environment variables, Docker Compose examples, supported file types, and privacy considerations. https://claude.ai/code/session_01TSkKc7a9Yu2ugm1RvSf4dh * No need to specify `imported` in code * Missed a couple more places * Tiny reordering for the human OCD * Update app/models/assistant/function/search_family_files.rb Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Juan José Mata <jjmata@jjmata.com> * PR comments * More PR comments --------- Signed-off-by: Juan José Mata <jjmata@jjmata.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
229 lines
8.0 KiB
Ruby
229 lines
8.0 KiB
Ruby
class Family < ApplicationRecord
|
|
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
|
|
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable
|
|
include CoinbaseConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
|
|
include IndexaCapitalConnectable
|
|
|
|
DATE_FORMATS = [
|
|
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
|
[ "DD.MM.YYYY", "%d.%m.%Y" ],
|
|
[ "DD-MM-YYYY", "%d-%m-%Y" ],
|
|
[ "YYYY-MM-DD", "%Y-%m-%d" ],
|
|
[ "DD/MM/YYYY", "%d/%m/%Y" ],
|
|
[ "YYYY/MM/DD", "%Y/%m/%d" ],
|
|
[ "MM/DD/YYYY", "%m/%d/%Y" ],
|
|
[ "D/MM/YYYY", "%e/%m/%Y" ],
|
|
[ "YYYY.MM.DD", "%Y.%m.%d" ],
|
|
[ "YYYYMMDD", "%Y%m%d" ]
|
|
].freeze
|
|
|
|
has_many :users, dependent: :destroy
|
|
has_many :accounts, dependent: :destroy
|
|
has_many :invitations, dependent: :destroy
|
|
|
|
has_many :imports, dependent: :destroy
|
|
has_many :family_exports, dependent: :destroy
|
|
|
|
has_many :entries, through: :accounts
|
|
has_many :transactions, through: :accounts
|
|
has_many :rules, dependent: :destroy
|
|
has_many :trades, through: :accounts
|
|
has_many :holdings, through: :accounts
|
|
|
|
has_many :tags, dependent: :destroy
|
|
has_many :categories, dependent: :destroy
|
|
has_many :merchants, dependent: :destroy, class_name: "FamilyMerchant"
|
|
|
|
has_many :budgets, dependent: :destroy
|
|
has_many :budget_categories, through: :budgets
|
|
|
|
has_many :llm_usages, dependent: :destroy
|
|
has_many :recurring_transactions, dependent: :destroy
|
|
|
|
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
|
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
|
|
validates :month_start_day, inclusion: { in: 1..28 }
|
|
|
|
def uses_custom_month_start?
|
|
month_start_day != 1
|
|
end
|
|
|
|
def custom_month_start_for(date)
|
|
if date.day >= month_start_day
|
|
Date.new(date.year, date.month, month_start_day)
|
|
else
|
|
previous_month = date - 1.month
|
|
Date.new(previous_month.year, previous_month.month, month_start_day)
|
|
end
|
|
end
|
|
|
|
def custom_month_end_for(date)
|
|
start_date = custom_month_start_for(date)
|
|
next_month_start = start_date + 1.month
|
|
next_month_start - 1.day
|
|
end
|
|
|
|
def current_custom_month_period
|
|
start_date = custom_month_start_for(Date.current)
|
|
end_date = custom_month_end_for(Date.current)
|
|
Period.custom(start_date: start_date, end_date: end_date)
|
|
end
|
|
|
|
def assigned_merchants
|
|
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
|
|
Merchant.where(id: merchant_ids)
|
|
end
|
|
|
|
def available_merchants
|
|
assigned_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
|
|
recently_unlinked_ids = FamilyMerchantAssociation
|
|
.where(family: self)
|
|
.recently_unlinked
|
|
.pluck(:merchant_id)
|
|
family_merchant_ids = merchants.pluck(:id)
|
|
Merchant.where(id: (assigned_ids + recently_unlinked_ids + family_merchant_ids).uniq)
|
|
end
|
|
|
|
def auto_categorize_transactions_later(transactions, rule_run_id: nil)
|
|
AutoCategorizeJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
|
|
end
|
|
|
|
def auto_categorize_transactions(transaction_ids)
|
|
AutoCategorizer.new(self, transaction_ids: transaction_ids).auto_categorize
|
|
end
|
|
|
|
def auto_detect_transaction_merchants_later(transactions, rule_run_id: nil)
|
|
AutoDetectMerchantsJob.perform_later(self, transaction_ids: transactions.pluck(:id), rule_run_id: rule_run_id)
|
|
end
|
|
|
|
def auto_detect_transaction_merchants(transaction_ids)
|
|
AutoMerchantDetector.new(self, transaction_ids: transaction_ids).auto_detect
|
|
end
|
|
|
|
def balance_sheet
|
|
@balance_sheet ||= BalanceSheet.new(self)
|
|
end
|
|
|
|
def income_statement
|
|
@income_statement ||= IncomeStatement.new(self)
|
|
end
|
|
|
|
# Returns the Investment Contributions category for this family, creating it if it doesn't exist.
|
|
# This is used for auto-categorizing transfers to investment accounts.
|
|
def investment_contributions_category
|
|
categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat|
|
|
cat.color = "#0d9488"
|
|
cat.classification = "expense"
|
|
cat.lucide_icon = "trending-up"
|
|
end
|
|
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
|
|
categories.find_by(name: Category.investment_contributions_name)
|
|
end
|
|
|
|
# Returns account IDs for tax-advantaged accounts (401k, IRA, HSA, etc.)
|
|
# Used to exclude these accounts from budget/cashflow calculations.
|
|
# Tax-advantaged accounts are retirement savings, not daily expenses.
|
|
def tax_advantaged_account_ids
|
|
@tax_advantaged_account_ids ||= begin
|
|
# Investment accounts derive tax_treatment from subtype
|
|
tax_advantaged_subtypes = Investment::SUBTYPES.select do |_, meta|
|
|
meta[:tax_treatment].in?(%i[tax_deferred tax_exempt tax_advantaged])
|
|
end.keys
|
|
|
|
investment_ids = accounts
|
|
.joins("INNER JOIN investments ON investments.id = accounts.accountable_id AND accounts.accountable_type = 'Investment'")
|
|
.where(investments: { subtype: tax_advantaged_subtypes })
|
|
.pluck(:id)
|
|
|
|
# Crypto accounts have an explicit tax_treatment column
|
|
crypto_ids = accounts
|
|
.joins("INNER JOIN cryptos ON cryptos.id = accounts.accountable_id AND accounts.accountable_type = 'Crypto'")
|
|
.where(cryptos: { tax_treatment: %w[tax_deferred tax_exempt] })
|
|
.pluck(:id)
|
|
|
|
investment_ids + crypto_ids
|
|
end
|
|
end
|
|
|
|
def investment_statement
|
|
@investment_statement ||= InvestmentStatement.new(self)
|
|
end
|
|
|
|
def eu?
|
|
country != "US" && country != "CA"
|
|
end
|
|
|
|
def requires_securities_data_provider?
|
|
# If family has any trades, they need a provider for historical prices
|
|
trades.any?
|
|
end
|
|
|
|
def requires_exchange_rates_data_provider?
|
|
# If family has any accounts not denominated in the family's currency, they need a provider for historical exchange rates
|
|
return true if accounts.where.not(currency: self.currency).any?
|
|
|
|
# If family has any entries in different currencies, they need a provider for historical exchange rates
|
|
uniq_currencies = entries.pluck(:currency).uniq
|
|
return true if uniq_currencies.count > 1
|
|
return true if uniq_currencies.count > 0 && uniq_currencies.first != self.currency
|
|
|
|
false
|
|
end
|
|
|
|
def missing_data_provider?
|
|
(requires_securities_data_provider? && Security.provider.nil?) ||
|
|
(requires_exchange_rates_data_provider? && ExchangeRate.provider.nil?)
|
|
end
|
|
|
|
# Returns securities with plan restrictions for a specific provider
|
|
# @param provider [String] The provider name (e.g., "TwelveData")
|
|
# @return [Array<Hash>] Array of hashes with ticker, name, required_plan, provider
|
|
def securities_with_plan_restrictions(provider:)
|
|
security_ids = trades.joins(:security).pluck("securities.id").uniq
|
|
return [] if security_ids.empty?
|
|
|
|
restrictions = Security.plan_restrictions_for(security_ids, provider: provider)
|
|
return [] if restrictions.empty?
|
|
|
|
Security.where(id: restrictions.keys).map do |security|
|
|
restriction = restrictions[security.id]
|
|
{
|
|
ticker: security.ticker,
|
|
name: security.name,
|
|
required_plan: restriction[:required_plan],
|
|
provider: restriction[:provider]
|
|
}
|
|
end
|
|
end
|
|
|
|
def oldest_entry_date
|
|
entries.order(:date).first&.date || Date.current
|
|
end
|
|
|
|
# Used for invalidating family / balance sheet related aggregation queries
|
|
def build_cache_key(key, invalidate_on_data_updates: false)
|
|
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
|
|
# By including it in the cache key, we can expire caches every time family account data changes.
|
|
data_invalidation_key = invalidate_on_data_updates ? latest_sync_completed_at : nil
|
|
|
|
[
|
|
id,
|
|
key,
|
|
data_invalidation_key,
|
|
accounts.maximum(:updated_at)
|
|
].compact.join("_")
|
|
end
|
|
|
|
# Used for invalidating entry related aggregation queries
|
|
def entries_cache_version
|
|
@entries_cache_version ||= begin
|
|
ts = entries.maximum(:updated_at)
|
|
ts.present? ? ts.to_i : 0
|
|
end
|
|
end
|
|
|
|
def self_hoster?
|
|
Rails.application.config.app_mode.self_hosted?
|
|
end
|
|
end
|