mirror of
https://github.com/we-promise/sure.git
synced 2026-05-11 14:45:01 +00:00
Shifts the AI assistant from reactive (users ask questions) to proactive (the system surfaces personalized insights automatically). A nightly job analyzes every family's financial data across 7 insight types, writes natural-language explanations via Claude, and surfaces them in a feed on the dashboard and a standalone /insights page. Feature is behind a flag: off by default, enable with INSIGHTS_ENABLED=1 (or via Setting.insights_enabled in the admin UI). Insight types: - spending_anomaly: category spend >25% above/below 3-month rolling average - cash_flow_warning: projected cash balance drops below $500 in 30 days (uses RecurringTransaction + statistical daily baseline) - net_worth_milestone: crossed a round-number milestone or all-time high - subscription_audit: recurring transaction overdue by 45+ days - savings_rate_change: savings rate changed >5 percentage points vs last month - idle_cash: $5k+ sitting in depository account with no activity in 60 days - budget_at_risk / budget_on_track: spending pace vs monthly budget Architecture: - Insight model with dedup_key unique index (upsert, not re-create daily) - Insight::Generator base class + Insight::GeneratorRegistry orchestrator - LLM used as a writer only — financial math runs in pure Ruby - GenerateInsightsJob runs at 6 AM UTC daily via sidekiq-cron - InsightsController with read/dismiss Turbo Stream actions - Dashboard section gated by Current.user.insights_enabled? https://claude.ai/code/session_014vY9xohpm3abSAxVxRF27a
42 lines
1.2 KiB
Ruby
42 lines
1.2 KiB
Ruby
class Insight < ApplicationRecord
|
|
belongs_to :family
|
|
|
|
INSIGHT_TYPES = %w[
|
|
spending_anomaly
|
|
cash_flow_warning
|
|
net_worth_milestone
|
|
subscription_audit
|
|
savings_rate_change
|
|
idle_cash
|
|
budget_on_track
|
|
budget_at_risk
|
|
].freeze
|
|
|
|
PRIORITIES = %w[high medium low].freeze
|
|
STATUSES = %w[active read dismissed].freeze
|
|
|
|
enum :insight_type, INSIGHT_TYPES.index_by(&:itself), validate: true
|
|
enum :priority, PRIORITIES.index_by(&:itself), validate: true
|
|
enum :status, STATUSES.index_by(&:itself), validate: true
|
|
|
|
validates :title, :body, :dedup_key, presence: true
|
|
validates :insight_type, :priority, :status, presence: true
|
|
|
|
scope :visible, -> { where(status: %w[active read]) }
|
|
scope :for_dashboard, -> { visible.ordered.limit(3) }
|
|
scope :recent, -> { where(generated_at: 30.days.ago..) }
|
|
scope :ordered, -> {
|
|
order(
|
|
Arel.sql("CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 END ASC, generated_at DESC")
|
|
)
|
|
}
|
|
|
|
def mark_read!
|
|
update!(status: :read, read_at: Time.current) if active?
|
|
end
|
|
|
|
def dismiss!
|
|
update!(status: :dismissed, dismissed_at: Time.current)
|
|
end
|
|
end
|