Return artifacts from YNAB budget, transaction and spending tools

Convert ynab_budget_summary, ynab_search_transactions, ynab_spending_report
and ynab_category_budget to content_and_artifact (ynab.summary,
ynab.transactions, ynab.spending_report, ynab.category_budget). The text
the LLM sees is unchanged. Not-synced, bad-input, empty and error paths
return no artifact; Decimals become floats and dates ISO strings.

ynab_search_transactions now fetches up to 500 rows in one query (with an
id tie-break so ordering is stable) and lists the first `limit` in the
text; the artifact carries all of them. ynab_account_balances,
ynab_recent_changes and ynab_sql stay text-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 18:12:23 -04:00
parent 50d123e4d2
commit 20ec7ca8b6
3 changed files with 566 additions and 20 deletions
+427
View File
@@ -0,0 +1,427 @@
"""Tests for the YNAB tools' content_and_artifact output.
`ensure_fresh` and the `_query` DB helper are mocked; the fake answers each
query by matching a fragment of its SQL.
"""
import json
import re
from datetime import date
from decimal import Decimal
from types import SimpleNamespace
import pytest
from langchain_core.messages import ToolMessage
from blueprints.ynab import tools
KIND_DATA_KEYS = {
"ynab.summary": {
"month",
"ready_to_assign",
"income",
"assigned",
"spent",
"net_worth",
"overspent",
},
"ynab.transactions": {"start_date", "end_date", "count", "total", "transactions"},
"ynab.spending_report": {"start_date", "end_date", "group_by", "rows", "total"},
"ynab.category_budget": {"month", "groups"},
}
async def call(tool, args=None) -> ToolMessage:
"""Invoke a tool the way the agent does: with a ToolCall dict."""
msg = await tool.ainvoke(
{"type": "tool_call", "id": "call-1", "name": tool.name, "args": args or {}}
)
assert isinstance(msg, ToolMessage)
return msg
def assert_artifact(artifact, kind, optional=()):
assert artifact is not None
assert artifact["kind"] == kind
assert isinstance(artifact["title"], str) and artifact["title"]
assert KIND_DATA_KEYS[kind] <= set(artifact["data"])
assert set(artifact["data"]) <= KIND_DATA_KEYS[kind] | set(optional)
json.dumps(artifact)
def fake_db(monkeypatch, responses: dict, synced=True):
"""Route `_query` calls to `responses` by SQL fragment; honour LIMIT."""
calls = []
async def fake_query(sql, *params):
calls.append((sql, params))
for fragment, rows in responses.items():
if fragment in sql:
limit = re.search(r"LIMIT (\d+)", sql)
return rows[: int(limit.group(1))] if limit else rows
raise AssertionError(f"unexpected query: {sql}")
async def fake_ensure_fresh():
return SimpleNamespace(budget_name="Home") if synced else None
monkeypatch.setattr(tools, "_query", fake_query)
monkeypatch.setattr(tools, "ensure_fresh", fake_ensure_fresh)
monkeypatch.setattr(tools, "_today", lambda: date(2026, 10, 12))
return calls
# --- ynab_budget_summary ----------------------------------------------------
SUMMARY_DB = {
"FROM ynab_months": [
{
"to_be_budgeted": Decimal("120.50"),
"income": Decimal("5000.00"),
"budgeted": Decimal("4800.00"),
"age_of_money": 42,
}
],
"mc.balance < 0": [{"name": "Dining Out", "balance": Decimal("-23.10")}],
"AS spent": [{"spent": Decimal("3100.25")}],
"GROUP BY on_budget": [
{"on_budget": True, "total": Decimal("10000.00")},
{"on_budget": False, "total": Decimal("-2500.00")},
],
}
async def test_budget_summary(monkeypatch):
fake_db(monkeypatch, SUMMARY_DB)
msg = await call(tools.ynab_budget_summary, {"month": "2026-10"})
assert msg.content == (
"YNAB budget 'Home' for 2026-10:\n"
"Ready to Assign: $120.50\n"
"Income: $5,000.00\n"
"Assigned: $4,800.00\n"
"Spent: $3,100.25\n"
"Age of money: 42 days\n"
"Overspent: Dining Out (-$23.10)\n"
"Net worth: $7,500.00 (budget accounts $10,000.00, "
"tracking accounts -$2,500.00)"
)
art = msg.artifact
assert_artifact(art, "ynab.summary", optional={"age_of_money"})
assert art["title"] == "Budget · October 2026"
assert art["data"] == {
"month": "2026-10",
"ready_to_assign": 120.5,
"income": 5000.0,
"assigned": 4800.0,
"spent": 3100.25,
"age_of_money": 42,
"net_worth": 7500.0,
"overspent": [{"name": "Dining Out", "available": -23.1}],
}
async def test_budget_summary_without_age_of_money(monkeypatch):
db = dict(SUMMARY_DB)
db["FROM ynab_months"] = [
{**SUMMARY_DB["FROM ynab_months"][0], "age_of_money": None}
]
fake_db(monkeypatch, db)
msg = await call(tools.ynab_budget_summary, {"month": "2026-10"})
assert "Age of money" not in msg.content
assert "age_of_money" not in msg.artifact["data"]
async def test_budget_summary_no_month_data(monkeypatch):
fake_db(monkeypatch, {"FROM ynab_months": []})
msg = await call(tools.ynab_budget_summary, {"month": "2026-10"})
assert msg.content == "No YNAB budget data for 2026-10."
assert msg.artifact is None
# --- ynab_search_transactions -----------------------------------------------
def _txn(i, amount, **extra):
return {
"id": f"t{i}",
"date": date(2026, 10, 12 - i % 10),
"amount": Decimal(amount),
"memo": None,
"approved": True,
"account_name": "Checking",
"payee_name": "Costco",
"category_name": "Groceries",
**extra,
}
TXNS = [
_txn(0, "-54.20", memo="milk"),
_txn(1, "-12.00", approved=False, payee_name=None, category_name=None),
_txn(2, "100.00"),
]
TXN_TOTALS = {
"count(*) AS n": [
{"n": 3, "outflow": Decimal("-66.20"), "inflow": Decimal("100.00")}
],
}
async def test_search_transactions(monkeypatch):
calls = fake_db(monkeypatch, {**TXN_TOTALS, "SELECT * FROM lines": TXNS})
msg = await call(tools.ynab_search_transactions, {"limit": 2})
assert msg.content == (
"Found 3 transactions from 2026-09-12 to 2026-10-12. "
"Outflows -$66.20, inflows $100.00.\n"
"- 2026-10-12: Costco -$54.20 (Groceries; Checking) — milk\n"
"- 2026-10-11: Unknown payee -$12.00 (Uncategorized; Checking) [unapproved]\n"
"(Showing 2 most recent of 3)"
)
assert "LIMIT 500" in calls[-1][0]
art = msg.artifact
assert_artifact(art, "ynab.transactions")
assert art["title"] == "Transactions · Sep 12–Oct 12"
data = art["data"]
assert data["start_date"] == "2026-09-12"
assert data["end_date"] == "2026-10-12"
assert data["count"] == 3
assert data["total"] == pytest.approx(33.8)
# the artifact keeps every row, not just the `limit` shown to the model
assert len(data["transactions"]) == 3
assert data["transactions"][0] == {
"date": "2026-10-12",
"payee": "Costco",
"category": "Groceries",
"account": "Checking",
"memo": "milk",
"amount": -54.2,
}
assert "memo" not in data["transactions"][1]
async def test_search_transactions_caps_artifact_rows(monkeypatch):
many = [_txn(i, "-1.00") for i in range(600)]
totals = {"count(*) AS n": [{"n": 600, "outflow": Decimal("-600"), "inflow": 0}]}
fake_db(monkeypatch, {**totals, "SELECT * FROM lines": many})
msg = await call(tools.ynab_search_transactions, {"payee": "costco"})
assert msg.content.endswith("(Showing 20 most recent of 600)")
assert len(msg.artifact["data"]["transactions"]) == tools.ARTIFACT_MAX_TRANSACTIONS
assert msg.artifact["data"]["start_date"] is None
assert msg.artifact["title"] == "Transactions · through 2026-10-12"
async def test_search_transactions_empty(monkeypatch):
fake_db(monkeypatch, {"count(*) AS n": [{"n": 0, "outflow": 0, "inflow": 0}]})
msg = await call(tools.ynab_search_transactions, {})
assert msg.content == "No transactions found from 2026-09-12 to 2026-10-12."
assert msg.artifact is None
async def test_search_transactions_bad_date(monkeypatch):
fake_db(monkeypatch, {})
msg = await call(tools.ynab_search_transactions, {"start_date": "nope"})
assert msg.content == "Dates must be YYYY-MM-DD."
assert msg.artifact is None
# --- ynab_spending_report ---------------------------------------------------
SPENDING_ROWS = [
{"key": "Groceries", "spent": Decimal("400.00"), "n": 10},
{"key": "Dining Out", "spent": Decimal("150.50"), "n": 5},
{"key": "Gas", "spent": Decimal("60.00"), "n": 2},
]
async def test_spending_report(monkeypatch):
fake_db(monkeypatch, {"GROUP BY 1": SPENDING_ROWS})
msg = await call(tools.ynab_spending_report, {"top": 2})
assert msg.content == (
"Spending by category from 2026-10-01 to 2026-10-12: total $610.50\n"
"- Groceries: $400.00 (10 txns)\n"
"- Dining Out: $150.50 (5 txns)\n"
"- 1 others: $60.00"
)
art = msg.artifact
assert_artifact(art, "ynab.spending_report")
assert art["title"] == "Spending by category · Oct 1–12"
assert art["data"] == {
"start_date": "2026-10-01",
"end_date": "2026-10-12",
"group_by": "category",
"rows": [
{"key": "Groceries", "amount": 400.0, "count": 10},
{"key": "Dining Out", "amount": 150.5, "count": 5},
{"key": "Gas", "amount": 60.0, "count": 2},
],
"total": 610.5,
}
async def test_spending_report_by_month(monkeypatch):
rows = [
{"key": "2026-09", "spent": Decimal("100"), "n": 1},
{"key": "2026-10", "spent": Decimal("200"), "n": 2},
]
fake_db(monkeypatch, {"GROUP BY 1": rows})
msg = await call(
tools.ynab_spending_report,
{"group_by": "month", "start_date": "2026-09-01"},
)
assert msg.content.endswith("Monthly average: $150.00")
assert [r["key"] for r in msg.artifact["data"]["rows"]] == ["2026-09", "2026-10"]
async def test_spending_report_empty_and_invalid(monkeypatch):
fake_db(monkeypatch, {"GROUP BY 1": []})
msg = await call(tools.ynab_spending_report, {})
assert msg.content == "No spending found from 2026-10-01 to 2026-10-12."
assert msg.artifact is None
msg = await call(tools.ynab_spending_report, {"group_by": "vibes"})
assert msg.content == (
"group_by must be one of: category, category_group, payee, account, month."
)
assert msg.artifact is None
# --- ynab_category_budget ---------------------------------------------------
def _cat(group, name, budgeted, activity, balance, goal=None):
return {
"group_name": group,
"name": name,
"goal_target": Decimal(goal) if goal else None,
"budgeted": Decimal(budgeted),
"activity": Decimal(activity),
"balance": Decimal(balance),
}
async def test_category_budget(monkeypatch):
rows = [
_cat("Bills", "Rent", "2000", "-2000", "0"),
_cat("Bills", "Unused", "0", "0", "0"),
_cat("Food", "Dining Out", "100", "-123.10", "-23.10", goal="150"),
]
fake_db(monkeypatch, {"FROM ynab_month_categories mc": rows})
msg = await call(tools.ynab_category_budget, {"month": "2026-10"})
assert msg.content == (
"Categories for 2026-10:\n"
"\nBills\n"
" Rent: assigned $2,000.00, activity -$2,000.00, available $0.00\n"
"\nFood\n"
" Dining Out: assigned $100.00, activity -$123.10, available -$23.10, "
"goal $150.00 [OVERSPENT]"
)
art = msg.artifact
assert_artifact(art, "ynab.category_budget")
assert art["title"] == "Category budget · October 2026"
assert art["data"] == {
"month": "2026-10",
"groups": [
{
"name": "Bills",
"categories": [
{
"name": "Rent",
"assigned": 2000.0,
"activity": -2000.0,
"available": 0.0,
}
],
},
{
"name": "Food",
"categories": [
{
"name": "Dining Out",
"assigned": 100.0,
"activity": -123.1,
"available": -23.1,
"goal_target": 150.0,
}
],
},
],
}
async def test_category_budget_all_zero_rows(monkeypatch):
rows = [_cat("Bills", "Unused", "0", "0", "0")]
fake_db(monkeypatch, {"FROM ynab_month_categories mc": rows})
msg = await call(tools.ynab_category_budget, {"month": "2026-10"})
assert msg.content == "Categories for 2026-10:"
assert msg.artifact is None
async def test_category_budget_empty(monkeypatch):
fake_db(monkeypatch, {"FROM ynab_month_categories mc": []})
msg = await call(tools.ynab_category_budget, {"month": "2026-10"})
assert msg.content == "No YNAB category data for 2026-10."
assert msg.artifact is None
# --- shared paths -----------------------------------------------------------
ARTIFACT_TOOLS = [
tools.ynab_budget_summary,
tools.ynab_search_transactions,
tools.ynab_spending_report,
tools.ynab_category_budget,
]
@pytest.mark.parametrize("tool", ARTIFACT_TOOLS, ids=lambda t: t.name)
async def test_not_synced(monkeypatch, tool):
fake_db(monkeypatch, {}, synced=False)
msg = await call(tool)
assert msg.content == tools.NOT_SYNCED
assert msg.artifact is None
@pytest.mark.parametrize("tool", ARTIFACT_TOOLS, ids=lambda t: t.name)
async def test_db_error(monkeypatch, tool):
fake_db(monkeypatch, {})
async def boom(sql, *params):
raise RuntimeError("db down")
monkeypatch.setattr(tools, "_query", boom)
msg = await call(tool)
assert msg.content == f"YNAB error in {tool.name}: RuntimeError: db down"
assert msg.artifact is None
def test_text_only_tools_unchanged():
assert tools.ynab_account_balances.response_format == "content"
assert tools.ynab_recent_changes.response_format == "content"