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 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,
)

View File

@@ -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,

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: