Files
sure/app/models/insight/generator.rb
Juan José Mata 66cf9e7f0b feat(insights): proactive financial intelligence feed (#2550)
* feat(insights): proactive financial intelligence feed

Adds a nightly job that analyzes each family's finances in pure Ruby and
surfaces typed, stateful insights on the dashboard and a new /insights page.

- Insight model (active/read/dismissed) with a per-family dedup_key unique
  index so nightly re-runs refresh rows instead of duplicating them
- Seven generators (spending anomaly, cash-flow warning, net worth milestone,
  subscription audit, savings rate change, idle cash, budget health) built on
  IncomeStatement, BalanceSheet, RecurringTransaction, and BudgetCategory
- LLM used as a writer, not a reasoner: Insight::BodyWriter narrates
  pre-computed facts via the configured provider, with an i18n template
  fallback so self-hosted installs without API keys work identically; bodies
  are only (re)written when an insight is new or its numbers changed
- GenerateInsightsJob: cron fan-out per family, per-family advisory lock,
  metadata-diff upsert that preserves read/dismissed state for unchanged
  signals and reactivates on material change
- Dashboard insights_feed section (top 3, collapsible/reorderable) and an
  /insights feed page with turbo-stream dismissal; viewing the feed marks
  insights read
- Tests for the model, job upsert semantics, and controller flows

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

* fix(insights): address Codex review — expiry, budget metadata, LLM usage

- Expire visible insights whose condition cleared: generators declare the
  insight types they produce, the registry reports which generators ran to
  completion, and the job expires visible insights of those types whose
  dedup_key was not regenerated. A crashing generator can't wipe out its
  healthy insights, and an expired insight reactivates when its condition
  returns — unlike a user-dismissed one, which stays dismissed.
- Include a bucketed budget-spent percent in budget_at_risk metadata so the
  body refreshes when overall usage moves >=10 points even if the same
  categories remain flagged.
- Pass family to chat_response so LLM narration is recorded in llm_usages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

* fix(insights): address CodeRabbit review

- Skip the mark-as-read write for Turbo hover-prefetch requests
  (X-Sec-Purpose) so unread badges don't clear before a real visit
- Show the New pill on the dashboard feed (active = unread there; the
  feed never marks insights read)
- Filter idle accounts in SQL instead of a per-account exists? loop
- Eager-load merchants in the subscription audit query
- Widen the advisory-lock key to the signed-bigint range and log when
  acquisition fails so a skipped nightly run is observable
- Mirror the dedup_key unique index as a model validation
- Assert the refresh action enqueues for the signed-in family

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

* fix(insights): clear stale lifecycle timestamps on reactivation

When an insight resurfaces, the row now leaves no contradictory state
behind: the material-change path clears both read_at and dismissed_at,
and the expired-recovery path clears read_at. Tests assert the contract,
and the dashboard feed test now also locks in the unread "New" badge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

* fix(insights): drop redundant standalone family_id index

Every composite index on insights already leads with family_id, so the
auto-created single-column index was pure write overhead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y6ZRA6cgCRM4UdFKct3wm4

* fix(insights): stop nightly drift from resurrecting dismissed insights

Three generators stored continuously drifting values (projected amounts,
starting balance, current net worth) in metadata, and any metadata diff
counts as a material change: the body is rewritten (an LLM call when a
provider is configured) and read/dismissed state is cleared. Dismissing a
cash-flow warning was undone at the next 6:00 run.

Metadata now carries only the signal identity plus a coarse bucket, the
same damping the budget generators already use; display values live in
facts. Also orders the idle-cash pick by balance so the selected accounts
don't flip between runs, and documents the dollar-scale threshold
assumption on the relevant constants.

* fix(ds): map DS::Button aria_label into the aria hash

A bare aria_label: option reaches the tag helpers as a literal aria_label
attribute (Rails only dasherizes the nested aria: hash), so the icon-only
fallback overrode it and screen readers announced the insight dismiss
button and the popover trigger as "X".

* fix(insights): gate LLM narration behind AI consent

The nightly job runs unprompted for every family, so narration now
requires someone in the family to have AI enabled: consent to share
financial facts with the provider, and a cost cap in managed mode. The
template fallback keeps behavior identical otherwise. Narration failures
are captured via DebugLogEntry so support can see them.

Adds BodyWriter coverage, including a template-interpolation test for
every generator template key.

* feat(insights): rework the dashboard feed

The feed rendered full insight cards inside the section shell — the only
widget nesting card-on-card — and appended below the fold for every
family with a saved section order. It now mirrors the outflows and
balance-sheet list idiom (inset well with an uppercase mini-header, white
row block, 28px sentiment-tinted icon circles via color-mix on the DS
CSS variables, right-aligned key figures) and leads the dashboard for
saved orders that predate it.

Icon color comes from sentiment, not priority — a savings-rate
improvement is high priority AND good news, and must not render red; red
is reserved for a projected-negative balance. Rows have no hover wash
(cursor plus a gentle icon scale, like the sibling widgets), links to
/insights disable Turbo prefetch so the mark-as-read actually fires for
mouse users, and the standard widget-size popover offers Half/Full. Full
stays the default: the feed is far shorter than any other single-width
widget, so a half default leaves a grid hole the masonry cannot backfill.

* feat(insights): actionable cards, dismiss undo, live refresh

Each row now persists its display facts (new jsonb column) alongside the
change-detection metadata. Facts refresh every run without touching the
body or user state, which is exactly why they are not part of the
material-change comparison.

The card gains what the stored data always supported: a type-and-period
meta line replacing "x minutes ago", a right-aligned key figure (green
only for good news), and a contextual link resolved from the subject ids
in metadata — category, account, recurring transaction, budget month —
omitted when the subject no longer exists.

Dismiss is forgiving: a toast in the notification tray offers undo, and
undismissing restores the row as read rather than re-badging it.
Milestone insights can never regenerate once dismissed, so this closes a
real loss path. Upsert failures are captured via DebugLogEntry.

Manual refresh gets feedback: the button swaps to a disabled checking
state, the page subscribes to a family-scoped stream, and the job
broadcasts the refreshed list and the idle button when it finishes (also
after a lock-skipped run, so the button cannot stay stuck).

Savings copy is sign-aware: a negative rate reads "you spent more than
you earned" with true minus signs. The empty state swaps Lucide sparkles
for the brand assistant glyph (DS::EmptyState learns icon_custom:).

* feat(insights): top-bar entry with unread count

The dashboard feed hides at zero insights and nothing else linked to
/insights, so the manual refresh (and the empty state) were unreachable
for exactly the families who need them: fresh setups before the first
6:00 run. The sticky top bar travels to every screen and its right
cluster had room, so insights get an icon entry there with a monochrome
unread badge. Prefetch is disabled on the link for the same
mark-as-read reason as the feed.

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Guillem Arias <accounts@gariasf.com>
2026-07-14 02:19:39 +02:00

94 lines
2.8 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Base class for insight generators. Subclasses compute a financial signal in
# pure Ruby and return GeneratedInsight records — numbers only, no prose. The
# prose (`body`) is written later by Insight::BodyWriter, and only when the
# insight is new or its numbers changed, so nightly re-runs don't re-invoke
# the LLM for unchanged insights.
class Insight::Generator
# `facts` doubles as the i18n interpolation args for the template fallback
# and as the grounding data handed to the LLM writer. `metadata` is what the
# job compares between runs to decide whether an insight changed — keep its
# values JSON-primitive (floats, strings, ISO dates) so comparisons are stable.
GeneratedInsight = Data.define(
:insight_type,
:priority,
:title,
:template_key,
:facts,
:metadata,
:currency,
:period_start,
:period_end,
:dedup_key
)
class << self
# Declares the insight_type values this generator can emit. The job uses
# this to expire stale insights: a visible insight whose type belongs to a
# generator that ran successfully, but whose dedup_key was not regenerated,
# has had its condition clear.
def produces(*types)
@produced_types = types.flatten.map(&:to_s)
end
def produced_types
@produced_types || []
end
end
def initialize(family)
@family = family
end
def generate
raise NotImplementedError
end
private
attr_reader :family
def income_statement
@income_statement ||= IncomeStatement.new(family)
end
def balance_sheet
@balance_sheet ||= BalanceSheet.new(family)
end
def build_insight(insight_type:, priority:, title:, template_key:, facts:, dedup_key:, metadata:, period: nil)
GeneratedInsight.new(
insight_type: insight_type,
priority: priority,
title: title,
template_key: template_key,
facts: facts,
metadata: metadata,
currency: family.currency,
period_start: period&.start_date,
period_end: period&.end_date,
dedup_key: dedup_key
)
end
def format_money(amount)
Money.new(amount, family.currency).format
end
# Normalizes BigDecimal/Rational math results so metadata survives a jsonb
# round-trip unchanged (BigDecimal#as_json is a string, which would make
# every nightly run look like a material change).
def round(amount, precision = 2)
amount.to_f.round(precision)
end
def month_token(date = Date.current)
date.strftime("%Y-%m")
end
# Formats a number for display facts with a true minus sign (U+2212) —
# the app types negatives with a minus, not a hyphen. Keep raw numerics
# in `metadata`; this is for interpolation into template/LLM prose only.
def signed_number(value)
value.negative? ? "#{value.abs}" : value.to_s
end
end