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:
Carlos Escalante
2026-04-29 22:01:50 -06:00
parent 5f2a4105f3
commit 7f602a67af
4 changed files with 540 additions and 0 deletions

View File

View 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
View 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,
]

View File

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