diff --git a/backend/app/agent/tools.py b/backend/app/agent/tools.py index d66910d..7a89407 100644 --- a/backend/app/agent/tools.py +++ b/backend/app/agent/tools.py @@ -3,6 +3,11 @@ 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. + +POLICY: every tool here must stay READ-ONLY. Transaction descriptions reach +the model from external emails (indirect prompt-injection surface), so a +write-capable tool would let crafted text mutate financial data. Any future +write tool requires an explicit user-confirmation step in the UI. """ from __future__ import annotations @@ -28,10 +33,13 @@ from app.models.models import ( WaterMeterReading, ) from app.services.budget_projection import ( + MAX_YEAR, + MIN_YEAR, compute_monthly_projection, compute_yearly_projection_with_cumulative, get_cycle_range, ) +from app.services import exchange_rate as fx from app.services.exchange_rate import ( get_converted_amount_expr, get_current_rate, @@ -49,7 +57,13 @@ def reset_session(token: contextvars.Token) -> None: def _s() -> Session: - return _session_ctx.get() + try: + return _session_ctx.get() + except LookupError as exc: + raise RuntimeError( + "DB session not bound to agent context — tool called outside an " + "HTTP request (the AG-UI middleware binds it per request)" + ) from exc # ─── Tools ────────────────────────────────────────────────────────────────── @@ -77,26 +91,31 @@ def get_accounts() -> list[dict]: 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 = float(rate.sell_rate) if rate else 600.0 + session = _s() + accounts = session.exec(select(Account)).all() + multipliers = fx.get_crc_multipliers(session) assets_crc = 0.0 liabilities_crc = 0.0 + excluded: list[str] = [] for a in accounts: - amt = float(a.balance) - if a.currency.value == "USD": - amt = float(a.balance) * sell - elif a.currency.value == "EUR": - amt = float(a.balance) * sell * 1.08 # rough; real conversion is endpoint-side + mult = multipliers.get(a.currency.value) + if mult is None: + # No live or last-known rate: say so instead of inventing a number + excluded.append(f"{a.label} ({a.currency.value})") + continue + amt = float(a.balance) * float(mult) if a.account_type.value == "LIABILITY": liabilities_crc += amt else: assets_crc += amt - return { + result = { "assets_crc": round(assets_crc, 2), "liabilities_crc": round(liabilities_crc, 2), "net_crc": round(assets_crc - liabilities_crc, 2), } + if excluded: + result["excluded_accounts_no_rate"] = excluded + return result def get_recent_transactions( @@ -218,6 +237,8 @@ def get_budget_projection( """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.""" + if not MIN_YEAR <= year <= MAX_YEAR: + return {"error": f"year must be between {MIN_YEAR} and {MAX_YEAR}"} session = _s() if month is None: months_data = compute_yearly_projection_with_cumulative(session, year) @@ -322,6 +343,7 @@ def get_salary_summary() -> dict: def get_municipal_receipts( limit: Annotated[int, Field(ge=1, le=50)] = 12, + offset: Annotated[int, Field(ge=0, description="Skip the N most recent")] = 0, account: Annotated[ Optional[str], Field(description="Municipal account/contract id") ] = None, @@ -331,7 +353,7 @@ def get_municipal_receipts( q = select(MunicipalReceipt).order_by(col(MunicipalReceipt.receipt_date).desc()) if account: q = q.where(MunicipalReceipt.account == account) - q = q.limit(limit) + q = q.offset(offset).limit(limit) rows = _s().exec(q).all() # One grouped query for all receipts instead of one per receipt consumption: dict[int, float] = {} @@ -387,13 +409,10 @@ def get_analytics_by_category( 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 + names = {c.id: c.name for c in session.exec(select(Category)).all()} out = [] for cat_id, total, count in rows: - name = "Uncategorized" - if cat_id: - cat = session.get(Category, cat_id) - if cat: - name = cat.name + name = names.get(cat_id, "Uncategorized") if cat_id else "Uncategorized" out.append( { "category_id": cat_id, @@ -460,6 +479,7 @@ def get_exchange_rate() -> dict: "buy_rate": float(rate.buy_rate), "sell_rate": float(rate.sell_rate), "date": rate.date.isoformat(), + "fetched_at": rate.fetched_at.isoformat() if rate.fetched_at else None, } diff --git a/backend/app/services/exchange_rate.py b/backend/app/services/exchange_rate.py index 66700fa..502d301 100644 --- a/backend/app/services/exchange_rate.py +++ b/backend/app/services/exchange_rate.py @@ -61,8 +61,8 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None: for datos in root.iter(): if datos.tag.endswith("NUM_VALOR"): return float(datos.text.strip().replace(",", ".")) - except Exception: - pass + except Exception as e: + logger.warning("BCCR rate fetch failed (indicator %s): %s", indicator, e) return None @@ -91,8 +91,8 @@ def _fetch_exchangerate_api() -> tuple[float, float] | None: crc = data["rates"].get("CRC") if crc: return _mid_to_buy_sell(float(crc)) - except Exception: - pass + except Exception as e: + logger.warning("ExchangeRate-API fetch failed: %s", e) return None @@ -106,7 +106,8 @@ def _fetch_currency_api() -> tuple[float, float] | None: crc = data.get("usd", {}).get("crc") if crc: return _mid_to_buy_sell(float(crc)) - except Exception: + except Exception as e: + logger.warning("currency-api fetch failed (%s): %s", url, e) continue return None @@ -120,8 +121,8 @@ def _fetch_floatrates() -> tuple[float, float] | None: crc_data = data.get("crc") if crc_data and "rate" in crc_data: return _mid_to_buy_sell(float(crc_data["rate"])) - except Exception: - pass + except Exception as e: + logger.warning("FloatRates fetch failed: %s", e) return None @@ -199,8 +200,8 @@ def _fetch_fiat_crc_mid(code: str) -> float | None: x = data["rates"].get(code) if crc and x: return float(crc) / float(x) - except Exception: - pass + except Exception as e: + logger.warning("%s/CRC fiat rate fetch failed: %s", code, e) return None @@ -220,8 +221,8 @@ def _fetch_crypto_crc(code: str) -> float | None: price = data.get(coin_id, {}).get("crc") if price: return float(price) - except Exception: - pass + except Exception as e: + logger.warning("CoinGecko %s/CRC fetch failed: %s", code, e) return None diff --git a/backend/tests/test_agent_tools.py b/backend/tests/test_agent_tools.py index 3233f8d..926a2fe 100644 --- a/backend/tests/test_agent_tools.py +++ b/backend/tests/test_agent_tools.py @@ -68,7 +68,7 @@ def make_receipt(period: str) -> MunicipalReceipt: def test_unbound_session_raises(session): - with pytest.raises(LookupError): + with pytest.raises(RuntimeError, match="not bound to agent context"): tools.get_accounts()