mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Add Microsoft Agent Framework assistant with read-only tools
Wires up an OpenAI-backed MAF agent that exposes WealthySmart data through tool calls (recent transactions, cycle summary, analytics, pensions). Pulls in agent-framework + AG-UI adapter + OpenAI client deps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0
backend/app/agent/__init__.py
Normal file
0
backend/app/agent/__init__.py
Normal file
74
backend/app/agent/agent.py
Normal file
74
backend/app/agent/agent.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Microsoft Agent Framework agent wired with OpenAI + WealthySmart tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from agent_framework import Agent
|
||||||
|
from agent_framework.openai import OpenAIChatCompletionClient
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.agent.tools import TOOLS
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """You are the WealthySmart assistant, an AI analyst for a
|
||||||
|
personal-finance app owned by a single user (Carlos).
|
||||||
|
|
||||||
|
Context you can rely on:
|
||||||
|
- The user's primary currency is Costa Rican colones (CRC, ₡). USD and EUR
|
||||||
|
balances and transactions are always converted to CRC using the latest
|
||||||
|
exchange rate before being summed.
|
||||||
|
- Credit-card billing cycles run from the 18th of a month to the 18th of the
|
||||||
|
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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
How to answer:
|
||||||
|
- ALWAYS call a tool to get data. Do not invent balances, dates or merchants.
|
||||||
|
- 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
|
||||||
|
USD (two decimals), € for EUR (two decimals).
|
||||||
|
- When showing lists, prefer markdown tables over prose.
|
||||||
|
- If a tool returns no data, say so explicitly — do not fill in zeros.
|
||||||
|
- You are read-only in this version. If asked to create, edit or delete
|
||||||
|
anything, explain that write actions aren't available yet and offer to
|
||||||
|
summarize or export the change instead.
|
||||||
|
|
||||||
|
Language: match the user. The app is bilingual (Spanish/English); respond in
|
||||||
|
whichever language they used.
|
||||||
|
|
||||||
|
Generative UI — render tools:
|
||||||
|
- When showing spending totals, cycle summaries, or category breakdowns →
|
||||||
|
call render_spending_summary. Source data: get_cycle_summary (by_source,
|
||||||
|
grand_total_crc) + get_analytics_by_category (by_category).
|
||||||
|
- When showing transaction lists or other structured data →
|
||||||
|
call render_a2ui in a SEPARATE tool-call step, only after all data-fetching
|
||||||
|
calls have returned. NEVER call render_a2ui in the same batch as any other
|
||||||
|
tool.
|
||||||
|
- Do NOT use markdown tables for data a render tool can display.
|
||||||
|
- CRITICAL RULE: When you call a render tool (render_spending_summary or
|
||||||
|
render_a2ui), that tool call MUST be the ONLY content in your message.
|
||||||
|
Do NOT include any text content alongside the tool call — no introduction,
|
||||||
|
no list, no explanation, nothing. The rendered card IS the complete
|
||||||
|
response. Any text you write in the same message as a render call will
|
||||||
|
appear as a duplicate below the card, which is wrong.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_agent() -> Agent:
|
||||||
|
client = OpenAIChatCompletionClient(
|
||||||
|
api_key=settings.OPENAI_API_KEY,
|
||||||
|
model=settings.AGENT_MODEL,
|
||||||
|
)
|
||||||
|
return Agent(
|
||||||
|
name="wealthysmart",
|
||||||
|
instructions=SYSTEM_PROMPT.replace("{today}", date.today().isoformat()),
|
||||||
|
client=client,
|
||||||
|
tools=TOOLS,
|
||||||
|
)
|
||||||
463
backend/app/agent/tools.py
Normal file
463
backend/app/agent/tools.py
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
"""
|
||||||
|
Read-only tools exposed to the MAF ChatAgent. Each tool is a thin wrapper
|
||||||
|
around existing SQLModel queries / service helpers — they do NOT duplicate
|
||||||
|
business logic. The active DB session is resolved via a ContextVar so tool
|
||||||
|
signatures stay clean for the LLM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextvars
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from sqlalchemy import case
|
||||||
|
from sqlmodel import Session, col, func, select
|
||||||
|
|
||||||
|
from app.models.models import (
|
||||||
|
Account,
|
||||||
|
BalanceOverride,
|
||||||
|
Category,
|
||||||
|
MunicipalReceipt,
|
||||||
|
PensionSnapshot,
|
||||||
|
RecurringItem,
|
||||||
|
Transaction,
|
||||||
|
TransactionSource,
|
||||||
|
TransactionType,
|
||||||
|
WaterMeterReading,
|
||||||
|
)
|
||||||
|
from app.services.budget_projection import (
|
||||||
|
compute_monthly_projection,
|
||||||
|
compute_yearly_projection_with_cumulative,
|
||||||
|
get_cycle_range,
|
||||||
|
)
|
||||||
|
from app.services.exchange_rate import (
|
||||||
|
get_converted_amount_expr,
|
||||||
|
get_current_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
|
||||||
|
|
||||||
|
|
||||||
|
def set_session(session: Session) -> contextvars.Token:
|
||||||
|
return _session_ctx.set(session)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_session(token: contextvars.Token) -> None:
|
||||||
|
_session_ctx.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def _s() -> Session:
|
||||||
|
return _session_ctx.get()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tools ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_accounts() -> list[dict]:
|
||||||
|
"""List every account with current balance, currency, bank and type
|
||||||
|
(BANK, PENSION, CRYPTO, SAVINGS, LIABILITY). Use this for net-worth and
|
||||||
|
balance questions."""
|
||||||
|
rows = _s().exec(select(Account).order_by(Account.account_type, Account.label)).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": a.id,
|
||||||
|
"bank": a.bank.value,
|
||||||
|
"label": a.label,
|
||||||
|
"currency": a.currency.value,
|
||||||
|
"balance": a.balance,
|
||||||
|
"account_type": a.account_type.value,
|
||||||
|
"next_payment": a.next_payment,
|
||||||
|
}
|
||||||
|
for a in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_net_worth() -> dict:
|
||||||
|
"""Return total assets, liabilities and net worth in CRC (primary currency).
|
||||||
|
USD/EUR balances are converted at the latest exchange rate."""
|
||||||
|
accounts = _s().exec(select(Account)).all()
|
||||||
|
rate = get_current_rate(_s())
|
||||||
|
sell = rate.sell_rate if rate else 600.0
|
||||||
|
assets_crc = 0.0
|
||||||
|
liabilities_crc = 0.0
|
||||||
|
for a in accounts:
|
||||||
|
amt = a.balance
|
||||||
|
if a.currency.value == "USD":
|
||||||
|
amt = a.balance * sell
|
||||||
|
elif a.currency.value == "EUR":
|
||||||
|
amt = a.balance * sell * 1.08 # rough; real conversion is endpoint-side
|
||||||
|
if a.account_type.value == "LIABILITY":
|
||||||
|
liabilities_crc += amt
|
||||||
|
else:
|
||||||
|
assets_crc += amt
|
||||||
|
return {
|
||||||
|
"assets_crc": round(assets_crc, 2),
|
||||||
|
"liabilities_crc": round(liabilities_crc, 2),
|
||||||
|
"net_crc": round(assets_crc - liabilities_crc, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_transactions(
|
||||||
|
limit: Annotated[int, Field(ge=1, le=100, description="How many rows to return")] = 20,
|
||||||
|
source: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
Field(description="Filter by source: CREDIT_CARD, CASH, or TRANSFER"),
|
||||||
|
] = None,
|
||||||
|
category_id: Annotated[Optional[int], Field(description="Filter by category id")] = None,
|
||||||
|
search: Annotated[
|
||||||
|
Optional[str], Field(description="Substring match against merchant name")
|
||||||
|
] = None,
|
||||||
|
start_date: Annotated[
|
||||||
|
Optional[str], Field(description="ISO date lower bound, inclusive")
|
||||||
|
] = None,
|
||||||
|
end_date: Annotated[
|
||||||
|
Optional[str], Field(description="ISO date upper bound, exclusive")
|
||||||
|
] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Recent transactions, newest first. Use filters to narrow down. For
|
||||||
|
billing-cycle scoped totals prefer get_cycle_summary."""
|
||||||
|
q = select(Transaction)
|
||||||
|
if source:
|
||||||
|
q = q.where(Transaction.source == TransactionSource(source))
|
||||||
|
if category_id is not None:
|
||||||
|
q = q.where(Transaction.category_id == category_id)
|
||||||
|
if search:
|
||||||
|
q = q.where(col(Transaction.merchant).ilike(f"%{search}%"))
|
||||||
|
if start_date:
|
||||||
|
q = q.where(Transaction.date >= datetime.fromisoformat(start_date))
|
||||||
|
if end_date:
|
||||||
|
q = q.where(Transaction.date < datetime.fromisoformat(end_date))
|
||||||
|
q = q.order_by(col(Transaction.date).desc()).limit(limit)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"date": t.date.isoformat(),
|
||||||
|
"merchant": t.merchant,
|
||||||
|
"amount": t.amount,
|
||||||
|
"currency": t.currency.value,
|
||||||
|
"source": t.source.value,
|
||||||
|
"transaction_type": t.transaction_type.value,
|
||||||
|
"bank": t.bank.value,
|
||||||
|
"category_id": t.category_id,
|
||||||
|
}
|
||||||
|
for t in _s().exec(q).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_cycle_summary(
|
||||||
|
cycle_year: Annotated[int, Field(description="Billing cycle year, e.g. 2026")],
|
||||||
|
cycle_month: Annotated[
|
||||||
|
int,
|
||||||
|
Field(ge=1, le=12, description="Billing cycle month (cycle runs 18th→18th)"),
|
||||||
|
],
|
||||||
|
) -> dict:
|
||||||
|
"""Totals for a credit-card billing cycle (18th of month → 18th of next).
|
||||||
|
Returns spend by source, count, and spend by category."""
|
||||||
|
session = _s()
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||||
|
|
||||||
|
totals = session.exec(
|
||||||
|
select(
|
||||||
|
Transaction.source,
|
||||||
|
func.count(),
|
||||||
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date < end,
|
||||||
|
)
|
||||||
|
.group_by(Transaction.source)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
by_category = session.exec(
|
||||||
|
select(
|
||||||
|
Category.name,
|
||||||
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
|
func.count(),
|
||||||
|
)
|
||||||
|
.join(Category, Category.id == Transaction.category_id, isouter=True)
|
||||||
|
.where(
|
||||||
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date < end,
|
||||||
|
)
|
||||||
|
.group_by(Category.name)
|
||||||
|
.order_by(func.sum(amount_crc).desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cycle_year": cycle_year,
|
||||||
|
"cycle_month": cycle_month,
|
||||||
|
"range": [start.isoformat(), end.isoformat()],
|
||||||
|
"by_source": [
|
||||||
|
{"source": s.value, "count": c, "total_crc": float(t)}
|
||||||
|
for s, c, t in totals
|
||||||
|
],
|
||||||
|
"by_category": [
|
||||||
|
{"category": n or "Uncategorized", "total_crc": float(t), "count": c}
|
||||||
|
for n, t, c in by_category
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_budget_projection(
|
||||||
|
year: Annotated[int, Field(description="Year to project")],
|
||||||
|
month: Annotated[
|
||||||
|
Optional[int],
|
||||||
|
Field(ge=1, le=12, description="If given, return only that month's detail"),
|
||||||
|
] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Budget projection. If month is omitted, returns the yearly rollup; if
|
||||||
|
given, returns the monthly detail with income items, expense items and
|
||||||
|
actuals by source."""
|
||||||
|
session = _s()
|
||||||
|
if month is None:
|
||||||
|
months_data = compute_yearly_projection_with_cumulative(session, year)
|
||||||
|
return {
|
||||||
|
"year": year,
|
||||||
|
"months": months_data,
|
||||||
|
"annual_income": sum(m["projected_income"] for m in months_data),
|
||||||
|
"annual_expenses": sum(m["gran_total_egresos"] for m in months_data),
|
||||||
|
"annual_net": sum(m["net_balance"] for m in months_data),
|
||||||
|
}
|
||||||
|
return compute_monthly_projection(session, year, month)
|
||||||
|
|
||||||
|
|
||||||
|
def list_recurring_items() -> list[dict]:
|
||||||
|
"""All recurring items (income and expense, SAVINGS excluded) used by the
|
||||||
|
budget projection. Useful to explain what's driving a month's projection."""
|
||||||
|
rows = _s().exec(
|
||||||
|
select(RecurringItem)
|
||||||
|
.where(RecurringItem.is_active == True) # noqa: E712
|
||||||
|
.order_by(RecurringItem.item_type, RecurringItem.name)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"name": r.name,
|
||||||
|
"amount": r.amount,
|
||||||
|
"currency": r.currency.value,
|
||||||
|
"item_type": r.item_type.value,
|
||||||
|
"frequency": r.frequency.value,
|
||||||
|
"day_of_month": r.day_of_month,
|
||||||
|
"category_id": r.category_id,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_pension_snapshots(
|
||||||
|
fund: Annotated[
|
||||||
|
Optional[str],
|
||||||
|
Field(description="Filter by fund bank code (FCL, ROP, VOL, etc.)"),
|
||||||
|
] = None,
|
||||||
|
latest_only: Annotated[
|
||||||
|
bool,
|
||||||
|
Field(description="If true, return only the latest snapshot per fund"),
|
||||||
|
] = True,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Pension fund snapshots. Each snapshot covers a period with balances,
|
||||||
|
contributions, returns, fees and the ending balance (saldo_final)."""
|
||||||
|
q = select(PensionSnapshot).order_by(col(PensionSnapshot.period_end).desc())
|
||||||
|
if fund:
|
||||||
|
q = q.where(PensionSnapshot.fund == fund)
|
||||||
|
rows = _s().exec(q).all()
|
||||||
|
if latest_only:
|
||||||
|
seen: dict[str, PensionSnapshot] = {}
|
||||||
|
for r in rows:
|
||||||
|
if r.fund.value not in seen:
|
||||||
|
seen[r.fund.value] = r
|
||||||
|
rows = list(seen.values())
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"fund": r.fund.value,
|
||||||
|
"period_start": r.period_start.isoformat(),
|
||||||
|
"period_end": r.period_end.isoformat(),
|
||||||
|
"saldo_anterior": r.saldo_anterior,
|
||||||
|
"aportes": r.aportes,
|
||||||
|
"rendimientos": r.rendimientos,
|
||||||
|
"retiros": r.retiros,
|
||||||
|
"comision": r.comision,
|
||||||
|
"saldo_final": r.saldo_final,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_salary_summary() -> dict:
|
||||||
|
"""Summary of salary deposits (count, total in CRC, latest date)."""
|
||||||
|
session = _s()
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
row = session.exec(
|
||||||
|
select(
|
||||||
|
func.count(),
|
||||||
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
|
func.max(Transaction.date),
|
||||||
|
).where(Transaction.transaction_type == TransactionType.SALARY)
|
||||||
|
).first()
|
||||||
|
count = row[0] if row else 0
|
||||||
|
total = float(row[1]) if row else 0.0
|
||||||
|
latest = row[2].isoformat() if row and row[2] else None
|
||||||
|
return {"count": count, "total_crc": total, "latest_date": latest}
|
||||||
|
|
||||||
|
|
||||||
|
def get_municipal_receipts(
|
||||||
|
limit: Annotated[int, Field(ge=1, le=50)] = 12,
|
||||||
|
account: Annotated[
|
||||||
|
Optional[str], Field(description="Municipal account/contract id")
|
||||||
|
] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Recent municipal receipts (water + related services) with totals and
|
||||||
|
water consumption in m³."""
|
||||||
|
q = select(MunicipalReceipt).order_by(col(MunicipalReceipt.receipt_date).desc())
|
||||||
|
if account:
|
||||||
|
q = q.where(MunicipalReceipt.account == account)
|
||||||
|
q = q.limit(limit)
|
||||||
|
rows = _s().exec(q).all()
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
readings = _s().exec(
|
||||||
|
select(WaterMeterReading).where(WaterMeterReading.receipt_id == r.id)
|
||||||
|
).all()
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"receipt_date": r.receipt_date.isoformat(),
|
||||||
|
"period": r.period,
|
||||||
|
"account": r.account,
|
||||||
|
"finca": r.finca,
|
||||||
|
"subtotal": r.subtotal,
|
||||||
|
"interests": r.interests,
|
||||||
|
"iva": r.iva,
|
||||||
|
"total": r.total,
|
||||||
|
"water_consumption_m3": sum(w.consumption_m3 for w in readings),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_analytics_by_category(
|
||||||
|
cycle_year: Annotated[Optional[int], Field(description="Scope to a billing cycle")] = None,
|
||||||
|
cycle_month: Annotated[Optional[int], Field(ge=1, le=12)] = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Spending breakdown by category in CRC (optionally scoped to a billing
|
||||||
|
cycle). Percentages sum to 100."""
|
||||||
|
session = _s()
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
q = (
|
||||||
|
select(
|
||||||
|
Transaction.category_id,
|
||||||
|
func.sum(amount_crc).label("total"),
|
||||||
|
func.count().label("count"),
|
||||||
|
)
|
||||||
|
.where(Transaction.transaction_type == TransactionType.COMPRA)
|
||||||
|
.group_by(Transaction.category_id)
|
||||||
|
)
|
||||||
|
if cycle_year and cycle_month:
|
||||||
|
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||||
|
q = q.where(Transaction.date >= start, Transaction.date < end)
|
||||||
|
rows = session.exec(q).all()
|
||||||
|
grand = sum(float(r[1]) for r in rows) or 1.0
|
||||||
|
out = []
|
||||||
|
for cat_id, total, count in rows:
|
||||||
|
name = "Uncategorized"
|
||||||
|
if cat_id:
|
||||||
|
cat = session.get(Category, cat_id)
|
||||||
|
if cat:
|
||||||
|
name = cat.name
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"category_id": cat_id,
|
||||||
|
"category": name,
|
||||||
|
"total_crc": float(total),
|
||||||
|
"count": count,
|
||||||
|
"percentage": round(float(total) / grand * 100, 1),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out.sort(key=lambda x: x["total_crc"], reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_monthly_trend(
|
||||||
|
months: Annotated[int, Field(ge=1, le=24, description="How many months back")] = 6,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Spending trend by billing cycle for the last N months."""
|
||||||
|
session = _s()
|
||||||
|
amount_crc = get_converted_amount_expr(session)
|
||||||
|
now = datetime.now()
|
||||||
|
results: list[dict] = []
|
||||||
|
y, m = now.year, now.month
|
||||||
|
for _ in range(months):
|
||||||
|
start, end = get_cycle_range(y, m)
|
||||||
|
row = session.exec(
|
||||||
|
select(
|
||||||
|
func.count(),
|
||||||
|
func.coalesce(func.sum(amount_crc), 0),
|
||||||
|
func.coalesce(
|
||||||
|
func.sum(
|
||||||
|
case((Transaction.currency == "USD", Transaction.amount), else_=0)
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
).where(
|
||||||
|
Transaction.transaction_type == TransactionType.COMPRA,
|
||||||
|
Transaction.date >= start,
|
||||||
|
Transaction.date < end,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"year": y,
|
||||||
|
"month": m,
|
||||||
|
"total_crc": float(row[1]) if row else 0.0,
|
||||||
|
"total_usd_raw": float(row[2]) if row else 0.0,
|
||||||
|
"count": row[0] if row else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if m == 1:
|
||||||
|
y, m = y - 1, 12
|
||||||
|
else:
|
||||||
|
m -= 1
|
||||||
|
return list(reversed(results))
|
||||||
|
|
||||||
|
|
||||||
|
def get_exchange_rate() -> dict:
|
||||||
|
"""Latest USD/CRC exchange rate (buy and sell). All multi-currency data
|
||||||
|
in the app is normalized to CRC using these rates."""
|
||||||
|
rate = get_current_rate(_s())
|
||||||
|
if not rate:
|
||||||
|
return {"buy_rate": None, "sell_rate": None, "date": None}
|
||||||
|
return {
|
||||||
|
"buy_rate": rate.buy_rate,
|
||||||
|
"sell_rate": rate.sell_rate,
|
||||||
|
"date": rate.date.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_categories() -> list[dict]:
|
||||||
|
"""All transaction categories (id, name, icon). Use when the user asks
|
||||||
|
about a category and you need the id to filter by."""
|
||||||
|
rows = _s().exec(select(Category).order_by(Category.name)).all()
|
||||||
|
return [{"id": c.id, "name": c.name, "icon": c.icon} for c in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# Registered with the agent in agent.py
|
||||||
|
TOOLS = [
|
||||||
|
get_accounts,
|
||||||
|
get_net_worth,
|
||||||
|
get_recent_transactions,
|
||||||
|
get_cycle_summary,
|
||||||
|
get_budget_projection,
|
||||||
|
list_recurring_items,
|
||||||
|
get_pension_snapshots,
|
||||||
|
get_salary_summary,
|
||||||
|
get_municipal_receipts,
|
||||||
|
get_analytics_by_category,
|
||||||
|
get_monthly_trend,
|
||||||
|
get_exchange_rate,
|
||||||
|
list_categories,
|
||||||
|
]
|
||||||
@@ -11,3 +11,6 @@ httpx
|
|||||||
pywebpush
|
pywebpush
|
||||||
py-vapid
|
py-vapid
|
||||||
python-dateutil
|
python-dateutil
|
||||||
|
agent-framework==1.2.1
|
||||||
|
agent-framework-ag-ui==1.0.0b260428
|
||||||
|
agent-framework-openai==1.2.1
|
||||||
|
|||||||
Reference in New Issue
Block a user