From 334d41bd9ad5414203d4d6a1f40a179fda33b4e0 Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Sat, 4 Jul 2026 18:27:46 -0600 Subject: [PATCH] =?UTF-8?q?Agent:=20fix=20date=20handling=20=E2=80=94=20Co?= =?UTF-8?q?sta=20Rica=20timezone=20and=20future=20cuotas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs from prod (server clock is UTC, user is UTC-6): - get_recent_transactions returned future-dated Tasa Cero cuotas; it now excludes anything dated after the user's today, same rule as /transactions/recent. - "¿Cuánto he gastado hoy?" answered for the wrong day: the prompt baked date.today() in at boot — frozen from startup AND in server-UTC (8 pm in Costa Rica is already tomorrow in UTC). The prompt no longer carries a date; a new get_current_date tool returns today in CR time plus the active billing cycle, and the prompt directs the model to call it for any relative date reference. today_cr() lives in timeutil (CR has no DST, fixed UTC-6). Co-Authored-By: Claude Fable 5 --- backend/app/agent/agent.py | 13 +++++++----- backend/app/agent/tools.py | 35 +++++++++++++++++++++++++++++-- backend/app/timeutil.py | 15 ++++++++++++- backend/tests/test_agent_tools.py | 34 ++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/backend/app/agent/agent.py b/backend/app/agent/agent.py index e6c3025..31101d4 100644 --- a/backend/app/agent/agent.py +++ b/backend/app/agent/agent.py @@ -2,8 +2,6 @@ from __future__ import annotations -from datetime import date - from agent_framework import Agent from agent_framework.openai import OpenAIChatCompletionClient @@ -22,8 +20,10 @@ Context you can rely on: following month. When the user says "this month" or "last month" without qualifiers, assume they mean the calendar month unless they mention "cycle", "corte", or their credit card. -- Today's date is {today}. Use it when the user says "this month", "last - month", "last year", etc. +- You do NOT have a reliable clock, and the server runs in UTC while the + user lives in Costa Rica (UTC-6). Whenever the question involves a + relative date — "hoy", "ayer", "este mes", "este ciclo", "el año + pasado" — call get_current_date FIRST and derive ranges from its answer. - Amounts are stored as raw numbers in their native currency (see `currency` field on transactions/accounts). Tools that return `total_crc` are already converted; tools that return per-transaction amounts are NOT. @@ -69,7 +69,10 @@ def build_agent() -> Agent: ) return Agent( name="wealthysmart", - instructions=SYSTEM_PROMPT.replace("{today}", date.today().isoformat()), + # No date baked in: build_agent() runs once at startup, so anything + # substituted here freezes at boot (and in server-UTC). The model + # gets "today" from the get_current_date tool instead. + instructions=SYSTEM_PROMPT, client=client, tools=TOOLS, ) diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index 7a89407..eab5084 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -13,7 +13,7 @@ write tool requires an explicit user-confirmation step in the UI. from __future__ import annotations import contextvars -from datetime import datetime +from datetime import datetime, time, timedelta from typing import Annotated, Optional from pydantic import Field @@ -40,6 +40,7 @@ from app.services.budget_projection import ( get_cycle_range, ) from app.services import exchange_rate as fx +from app.timeutil import today_cr from app.services.exchange_rate import ( get_converted_amount_expr, get_current_rate, @@ -140,7 +141,11 @@ def get_recent_transactions( q = select(Transaction).where( col(Transaction.transaction_type).notin_( [TransactionType.SALARY, TransactionType.DEPOSITO] - ) + ), + # Tasa Cero generates future-dated cuotas; "recent" means already + # billed (same rule as /transactions/recent). Bound is end of the + # user's today, Costa Rica time. + Transaction.date < datetime.combine(today_cr() + timedelta(days=1), time.min), ) if source: q = q.where(Transaction.source == TransactionSource(source)) @@ -491,7 +496,33 @@ def list_categories() -> list[dict]: # Registered with the agent in agent.py +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 + relative date reference — 'hoy', 'ayer', 'este mes', 'este ciclo', + 'el ciclo pasado' — the server clock and your own assumptions about + today are unreliable.""" + today = today_cr() + # get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before + # the 18th we are still in the cycle that started last month. + if today.day >= 18: + cycle_year, cycle_month = today.year, today.month + elif today.month == 1: + cycle_year, cycle_month = today.year - 1, 12 + else: + cycle_year, cycle_month = today.year, today.month - 1 + start, end = get_cycle_range(cycle_year, cycle_month) + return { + "date": today.isoformat(), + "weekday": today.strftime("%A"), + "cycle_year": cycle_year, + "cycle_month": cycle_month, + "cycle_range": [start.date().isoformat(), end.date().isoformat()], + } + + TOOLS = [ + get_current_date, get_accounts, get_net_worth, get_recent_transactions, diff --git a/backend/app/timeutil.py b/backend/app/timeutil.py index d3cdfa5..c88bf7b 100644 --- a/backend/app/timeutil.py +++ b/backend/app/timeutil.py @@ -1,4 +1,17 @@ -from datetime import datetime, timezone +from datetime import date, datetime, timedelta, timezone + +# Costa Rica has no DST; a fixed offset is exact year-round. +CR_TZ = timezone(timedelta(hours=-6)) + + +def today_cr() -> date: + """Current date in Costa Rica (UTC-6). + + The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow" + in UTC. Anything user-facing that means "today" (agent answers, date + defaults) must come from here, never date.today(). + """ + return datetime.now(CR_TZ).date() def utcnow() -> datetime: diff --git a/backend/tests/test_agent_tools.py b/backend/tests/test_agent_tools.py index 926a2fe..dd76903 100644 --- a/backend/tests/test_agent_tools.py +++ b/backend/tests/test_agent_tools.py @@ -149,3 +149,37 @@ def test_recent_transactions_json_safe(bound_session): out = tools.get_recent_transactions() json.dumps(out) assert out[0]["amount"] == 42.42 + + +def test_recent_transactions_exclude_future_cuotas(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("10"), merchant="BILLED", date=today) + ) + bound_session.add( + Transaction( + amount=Decimal("20"), + merchant="FUTURE CUOTA:03/06", + date=today + timedelta(days=45), + ) + ) + bound_session.commit() + merchants = [t["merchant"] for t in tools.get_recent_transactions()] + assert "BILLED" in merchants + assert "FUTURE CUOTA:03/06" not in merchants + + +def test_get_current_date_is_costa_rica_and_cycle_consistent(): + from app.timeutil import today_cr + + out = tools.get_current_date() + today = today_cr() + assert out["date"] == today.isoformat() + # The active cycle must contain today: [start, end) + start, end = out["cycle_range"] + assert start <= out["date"] < end + assert start.endswith("-18") and end.endswith("-18")