Files
sure/app/models/assistant/function/get_budget.rb
Wes 7685650e63 feat(assistant): add get_budget function for budget tracking (#1966)
* feat(assistant): add get_budget function for budget tracking

Exposes the existing Budget / BudgetCategory pacing data to the AI
assistant as a `get_budget` function. Supports a target month and an
optional `prior_months` window for trend comparison, with the response
shape matching the budget UI (totals, income, per-category status,
suggested daily spend on the current month).

Honors custom month_start_day by matching `Budget.param_to_date`
semantics for explicit slug input, so `month` round-trips with the
response's `month` field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(assistant): use fixture reference for Food & Drink lookup

Replace fragile string match on `bc.category.name == "Food & Drink"`
with the `categories(:food_and_drink)` fixture so the test setup
isn't sensitive to category-name translations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(assistant): enforce strict month format in get_budget

`Date.strptime` is lenient about trailing characters, so inputs like
`"2026-05-01"` or `"may-2026foo"` were parsing successfully and being
silently truncated to May 2026. Pre-validate the raw string with anchored
regex patterns for the documented YYYY-MM and MMM-YYYY shapes so
malformed tool arguments raise Assistant::Error instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(budgets): suggested_daily_spending handles custom-month periods

The helper compared `budget.start_date.month/year` against
`Date.current.month/year` and returned nil whenever the current period
straddled two calendar months — common for families with
`month_start_day != 1` (e.g., May 15–Jun 14 viewed on Jun 1). Replace
the calendar-month check with `budget.current?` and compute remaining
days from `budget.end_date` so the helper works for both standard and
custom periods. This also restores the daily pacing row in the budget
UI for custom-month families.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(assistant): make get_budget read-only for prior months

`prior_months: N` was calling `Budget.find_or_bootstrap` for every
month, which created empty `Budget` rows (and synced `BudgetCategory`
children) as a side effect of an AI query. Only the explicit target
month now bootstraps; prior months use `Budget.find_by` and are
dropped from the response if they don't exist. The response now
includes `months_unavailable: N` so the LLM can phrase a sensible
answer when fewer months come back than requested.

Extract `Budget.period_for(date, family:)` to share the date-bracket
math between `find_or_bootstrap`, `budget_date_valid?`, and the new
read-only path in `get_budget`.

Adds two tests covering the no-bootstrap behavior for prior months
and the `prior_months` clamp at `MAX_PRIOR_MONTHS`. Updates the
existing N+1 sorted-months test to seed prior budgets explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: wolstad <wesleyolstad@protonmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 00:51:16 +02:00

201 lines
6.6 KiB
Ruby

class Assistant::Function::GetBudget < Assistant::Function
include ActiveSupport::NumberHelper
MAX_PRIOR_MONTHS = 11
class << self
def name
"get_budget"
end
def description
<<~INSTRUCTIONS
Use this to see how the user is tracking against their monthly budget total
budgeted vs spent and a parent/subcategory breakdown matching the budget UI.
This is great for answering questions like:
- How am I tracking against my budget this month?
- Which categories am I over budget on?
- How does this month's spending compare to the last few months?
Parameters:
- `month` (optional): "YYYY-MM" or "MMM-YYYY". Defaults to the current month.
- `prior_months` (optional): integer 0..#{MAX_PRIOR_MONTHS}. Number of months
preceding the target month to include for trend comparison. Default 0.
Example (current month only):
```
get_budget({})
```
Example (current month plus last 2 months):
```
get_budget({ month: "#{Date.current.strftime('%Y-%m')}", prior_months: 2 })
```
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
properties: {
month: {
type: "string",
description: "Target month in YYYY-MM or MMM-YYYY format. Defaults to the current month."
},
prior_months: {
type: "integer",
description: "Number of months before the target month to also return for trend comparison.",
minimum: 0,
maximum: MAX_PRIOR_MONTHS
}
}
)
end
def call(params = {})
target_start = resolve_month_start(params["month"])
prior = [ params["prior_months"].to_i, 0 ].max
prior = [ prior, MAX_PRIOR_MONTHS ].min
month_starts = (0..prior).map { |offset| shift_months(target_start, -offset) }.reverse
requested = month_starts.count { |start_date| Budget.budget_date_valid?(start_date, family: family) }
months = month_starts.filter_map do |start_date|
next unless Budget.budget_date_valid?(start_date, family: family)
build_month_payload(start_date, bootstrap: start_date == target_start)
end
result = {
currency: family.currency,
months: months
}
unavailable = requested - months.length
result[:months_unavailable] = unavailable if unavailable > 0
result
end
private
def build_month_payload(start_date, bootstrap:)
budget = if bootstrap
Budget.find_or_bootstrap(family, start_date: start_date, user: user)
else
budget_start, budget_end = Budget.period_for(start_date, family: family)
family.budgets.find_by(start_date: budget_start, end_date: budget_end)
end
return nil unless budget
groups = BudgetCategory::Group.for(budget.budget_categories)
{
month: budget.to_param,
period: {
start_date: budget.start_date,
end_date: budget.end_date
},
is_current: budget.current?,
initialized: budget.initialized?,
totals: {
budgeted_spending: format_money(budget.budgeted_spending),
allocated_spending: format_money(budget.allocated_spending),
available_to_allocate: format_money(budget.available_to_allocate),
actual_spending: format_money(budget.actual_spending),
available_to_spend: format_money(budget.available_to_spend),
percent_of_budget_spent: format_percent(budget.initialized? ? budget.percent_of_budget_spent : 0),
overage_percent: format_percent(budget.overage_percent)
},
income: {
expected_income: format_money(budget.expected_income),
actual_income: format_money(budget.actual_income),
remaining_expected_income: format_money((budget.expected_income || 0) - budget.actual_income)
},
categories: groups.map { |group| serialize_group(group, include_daily_suggestion: budget.current?) }
}
end
def serialize_group(group, include_daily_suggestion:)
parent = group.budget_category
serialize_category(parent, include_daily_suggestion: include_daily_suggestion).merge(
color: parent.category.color,
subcategories: group.budget_subcategories.map do |sub|
serialize_category(sub, include_daily_suggestion: include_daily_suggestion).merge(
inherits_parent_budget: sub.inherits_parent_budget?
)
end
)
end
def serialize_category(bc, include_daily_suggestion:)
payload = {
name: bc.name,
budgeted: format_money(bc.display_budgeted_spending),
actual: format_money(bc.actual_spending),
available: format_money(bc.available_to_spend),
percent_spent: format_percent(bc.percent_of_budget_spent || 0),
status: category_status(bc)
}
if include_daily_suggestion
suggestion = bc.suggested_daily_spending
payload[:suggested_daily_spending] = suggestion[:amount].format if suggestion
end
payload
end
def category_status(bc)
return "over_budget" if bc.over_budget_with_budget?
return "unbudgeted" if bc.unbudgeted_with_spending?
return "near_limit" if bc.budgeted? && bc.near_limit?
return "on_track" if bc.on_track?
"no_activity"
end
def resolve_month_start(raw)
base = parse_month(raw)
return (base || Date.current).beginning_of_month unless family.uses_custom_month_start?
# Match Budget.param_to_date for explicit slugs so the input round-trips with the response.
base ? Date.new(base.year, base.month, family.month_start_day) : family.custom_month_start_for(Date.current)
end
def parse_month(raw)
return nil if raw.blank?
# Date.strptime ignores trailing characters, so guard with strict anchors first.
fmt = case raw
when /\A\d{4}-\d{2}\z/ then "%Y-%m"
when /\A[A-Za-z]{3}-\d{4}\z/ then "%b-%Y"
end
raise Assistant::Error, "Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY." if fmt.nil?
Date.strptime(raw, fmt)
rescue ArgumentError
raise Assistant::Error, "Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY."
end
def shift_months(date, n)
shifted = date >> n
if family.uses_custom_month_start?
family.custom_month_start_for(shifted)
else
shifted.beginning_of_month
end
end
def format_money(value)
Money.new(value || 0, family.currency).format
end
def format_percent(value)
number_to_percentage(value || 0, precision: 1)
end
end