Files
sure/app/models/insight.rb
Claude 8ae06e37e4 Add proactive financial intelligence feed
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
2026-04-12 12:12:49 +00:00

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