From 7f602a67af32da12c9adf0af9cdd0a022e993e2c Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Wed, 29 Apr 2026 22:01:50 -0600 Subject: [PATCH] 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) --- backend/app/agent/__init__.py | 0 backend/app/agent/agent.py | 74 ++++++ backend/app/agent/tools.py | 463 ++++++++++++++++++++++++++++++++++ backend/requirements.txt | 3 + 4 files changed, 540 insertions(+) create mode 100644 backend/app/agent/__init__.py create mode 100644 backend/app/agent/agent.py create mode 100644 backend/app/agent/tools.py diff --git a/backend/app/agent/__init__.py b/backend/app/agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/agent/agent.py b/backend/app/agent/agent.py new file mode 100644 index 0000000..fe71860 --- /dev/null +++ b/backend/app/agent/agent.py @@ -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, + ) diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py new file mode 100644 index 0000000..bbda99c --- /dev/null +++ b/backend/app/agent/tools.py @@ -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, +] diff --git a/backend/requirements.txt b/backend/requirements.txt index 2582ab7..6cac094 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,6 @@ httpx pywebpush py-vapid python-dateutil +agent-framework==1.2.1 +agent-framework-ag-ui==1.0.0b260428 +agent-framework-openai==1.2.1