mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 15:15:01 +00:00
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
This commit is contained in:
83
test/models/insight_test.rb
Normal file
83
test/models/insight_test.rb
Normal file
@@ -0,0 +1,83 @@
|
||||
require "test_helper"
|
||||
|
||||
class InsightTest < ActiveSupport::TestCase
|
||||
test "mark_read! transitions active to read and sets read_at" do
|
||||
insight = insights(:spending_anomaly_dining)
|
||||
assert insight.active?
|
||||
assert_nil insight.read_at
|
||||
|
||||
insight.mark_read!
|
||||
|
||||
assert insight.read?
|
||||
assert_not_nil insight.read_at
|
||||
end
|
||||
|
||||
test "mark_read! is a no-op when already read" do
|
||||
insight = insights(:net_worth_milestone)
|
||||
assert insight.read?
|
||||
original_read_at = insight.read_at
|
||||
|
||||
insight.mark_read!
|
||||
|
||||
assert_equal original_read_at, insight.reload.read_at
|
||||
end
|
||||
|
||||
test "dismiss! transitions to dismissed and sets dismissed_at" do
|
||||
insight = insights(:spending_anomaly_dining)
|
||||
assert insight.active?
|
||||
|
||||
insight.dismiss!
|
||||
|
||||
assert insight.dismissed?
|
||||
assert_not_nil insight.dismissed_at
|
||||
end
|
||||
|
||||
test "visible scope excludes dismissed insights" do
|
||||
dismissed = insights(:dismissed_insight)
|
||||
assert_not Insight.visible.include?(dismissed)
|
||||
end
|
||||
|
||||
test "visible scope includes active and read insights" do
|
||||
active = insights(:spending_anomaly_dining)
|
||||
read = insights(:net_worth_milestone)
|
||||
|
||||
assert Insight.visible.include?(active)
|
||||
assert Insight.visible.include?(read)
|
||||
end
|
||||
|
||||
test "ordered scope places high priority before medium before low" do
|
||||
high_insight = insights(:cash_flow_warning) # high
|
||||
medium_insight = insights(:spending_anomaly_dining) # medium
|
||||
|
||||
ordered = Insight.where(family: families(:dylan_family)).visible.ordered
|
||||
high_idx = ordered.index(high_insight)
|
||||
medium_idx = ordered.index(medium_insight)
|
||||
|
||||
assert_not_nil high_idx
|
||||
assert_not_nil medium_idx
|
||||
assert high_idx < medium_idx
|
||||
end
|
||||
|
||||
test "dedup_key uniqueness is enforced per family" do
|
||||
original = insights(:spending_anomaly_dining)
|
||||
|
||||
duplicate = Insight.new(
|
||||
family: original.family,
|
||||
insight_type: original.insight_type,
|
||||
priority: "low",
|
||||
status: "active",
|
||||
title: "Duplicate",
|
||||
body: "Duplicate body",
|
||||
metadata: {},
|
||||
currency: "USD",
|
||||
dedup_key: original.dedup_key,
|
||||
generated_at: Time.current
|
||||
)
|
||||
|
||||
assert_raises(ActiveRecord::RecordNotUnique) { duplicate.save!(validate: false) }
|
||||
end
|
||||
|
||||
test "for_dashboard scope returns at most 3 visible insights" do
|
||||
assert Insight.for_dashboard.count <= 3
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user