diff --git a/backend/app/agent/agent.py b/backend/app/agent/agent.py index 31101d4..ec2259e 100644 --- a/backend/app/agent/agent.py +++ b/backend/app/agent/agent.py @@ -30,6 +30,10 @@ Context you can rely on: How to answer: - ALWAYS call a tool to get data. Do not invent balances, dates or merchants. +- Match the tool to the question's time grain: day-scoped questions ("hoy", + "ayer", "el martes", a specific date) need get_daily_spending with that + day's bounds — cycle or category summaries have NO per-day data, so never + answer a day question from them. - Call multiple tools in parallel when the question spans domains (e.g. net worth + recent transactions). - Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index eab5084..a439052 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -35,6 +35,7 @@ from app.models.models import ( from app.services.budget_projection import ( MAX_YEAR, MIN_YEAR, + NOT_INSTALLMENT_ANCHOR, compute_monthly_projection, compute_yearly_projection_with_cumulative, get_cycle_range, @@ -496,6 +497,39 @@ def list_categories() -> list[dict]: # Registered with the agent in agent.py +def get_daily_spending( + start_date: Annotated[str, Field(description="ISO date lower bound, inclusive")], + end_date: Annotated[str, Field(description="ISO date upper bound, exclusive")], +) -> list[dict]: + """Spending per day in CRC (converted), COMPRA only, excluding future + Tasa Cero cuotas and installment anchors. THE tool for day-scoped + questions: 'cuánto gasté hoy / ayer / el martes'. Days with no spending + are simply absent from the result.""" + session = _s() + amount_crc = get_converted_amount_expr(session) + rows = session.exec( + select( + func.date(Transaction.date).label("day"), + func.coalesce(func.sum(amount_crc), 0), + func.count(), + ) + .where( + Transaction.transaction_type == TransactionType.COMPRA, + NOT_INSTALLMENT_ANCHOR, + Transaction.date >= datetime.fromisoformat(start_date), + Transaction.date < datetime.fromisoformat(end_date), + # Future-dated Tasa Cero cuotas are not money already spent. + Transaction.date < datetime.combine(today_cr() + timedelta(days=1), time.min), + ) + .group_by(func.date(Transaction.date)) + .order_by(func.date(Transaction.date)) + ).all() + return [ + {"date": str(day), "total_crc": float(total), "count": count} + for day, total, count in rows + ] + + def get_current_date() -> dict: """Today's date in the user's timezone (Costa Rica, UTC-6) and the active credit-card billing cycle. ALWAYS call this before resolving any @@ -523,6 +557,7 @@ def get_current_date() -> dict: TOOLS = [ get_current_date, + get_daily_spending, get_accounts, get_net_worth, get_recent_transactions, diff --git a/backend/tests/test_agent_tools.py b/backend/tests/test_agent_tools.py index dd76903..a0ac1df 100644 --- a/backend/tests/test_agent_tools.py +++ b/backend/tests/test_agent_tools.py @@ -183,3 +183,34 @@ def test_get_current_date_is_costa_rica_and_cycle_consistent(): start, end = out["cycle_range"] assert start <= out["date"] < end assert start.endswith("-18") and end.endswith("-18") + + +def test_daily_spending_day_scoped(bound_session): + from datetime import timedelta + + from app.timeutil import today_cr + + today = datetime.combine(today_cr(), datetime.min.time()) + bound_session.add( + Transaction(amount=Decimal("100"), merchant="TODAY-A", date=today.replace(hour=9)) + ) + bound_session.add( + Transaction(amount=Decimal("50"), merchant="TODAY-B", date=today.replace(hour=15)) + ) + bound_session.add( + Transaction( + amount=Decimal("999"), + merchant="FUTURE CUOTA", + date=today + timedelta(days=40), + ) + ) + bound_session.commit() + + out = tools.get_daily_spending( + start_date=today.date().isoformat(), + end_date=(today.date() + timedelta(days=60)).isoformat(), + ) + assert len(out) == 1 + assert out[0]["date"] == today.date().isoformat() + assert out[0]["total_crc"] == 150.0 + assert out[0]["count"] == 2