Agent: fix date handling — Costa Rica timezone and future cuotas
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 14s

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 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-07-04 18:27:46 -06:00
parent eb400ab1e9
commit 334d41bd9a
4 changed files with 89 additions and 8 deletions

View File

@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import date
from agent_framework import Agent from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient 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 following month. When the user says "this month" or "last month" without
qualifiers, assume they mean the calendar month unless they mention qualifiers, assume they mean the calendar month unless they mention
"cycle", "corte", or their credit card. "cycle", "corte", or their credit card.
- Today's date is {today}. Use it when the user says "this month", "last - You do NOT have a reliable clock, and the server runs in UTC while the
month", "last year", etc. 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` - Amounts are stored as raw numbers in their native currency (see `currency`
field on transactions/accounts). Tools that return `total_crc` are already field on transactions/accounts). Tools that return `total_crc` are already
converted; tools that return per-transaction amounts are NOT. converted; tools that return per-transaction amounts are NOT.
@@ -69,7 +69,10 @@ def build_agent() -> Agent:
) )
return Agent( return Agent(
name="wealthysmart", 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, client=client,
tools=TOOLS, tools=TOOLS,
) )

View File

@@ -13,7 +13,7 @@ write tool requires an explicit user-confirmation step in the UI.
from __future__ import annotations from __future__ import annotations
import contextvars import contextvars
from datetime import datetime from datetime import datetime, time, timedelta
from typing import Annotated, Optional from typing import Annotated, Optional
from pydantic import Field from pydantic import Field
@@ -40,6 +40,7 @@ from app.services.budget_projection import (
get_cycle_range, get_cycle_range,
) )
from app.services import exchange_rate as fx from app.services import exchange_rate as fx
from app.timeutil import today_cr
from app.services.exchange_rate import ( from app.services.exchange_rate import (
get_converted_amount_expr, get_converted_amount_expr,
get_current_rate, get_current_rate,
@@ -140,7 +141,11 @@ def get_recent_transactions(
q = select(Transaction).where( q = select(Transaction).where(
col(Transaction.transaction_type).notin_( col(Transaction.transaction_type).notin_(
[TransactionType.SALARY, TransactionType.DEPOSITO] [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: if source:
q = q.where(Transaction.source == TransactionSource(source)) q = q.where(Transaction.source == TransactionSource(source))
@@ -491,7 +496,33 @@ def list_categories() -> list[dict]:
# Registered with the agent in agent.py # 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 = [ TOOLS = [
get_current_date,
get_accounts, get_accounts,
get_net_worth, get_net_worth,
get_recent_transactions, get_recent_transactions,

View File

@@ -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: def utcnow() -> datetime:

View File

@@ -149,3 +149,37 @@ def test_recent_transactions_json_safe(bound_session):
out = tools.get_recent_transactions() out = tools.get_recent_transactions()
json.dumps(out) json.dumps(out)
assert out[0]["amount"] == 42.42 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")