BudgetBuddy / core /chat.py
KrishnaGarg's picture
Deploy BudgetBuddy update
7d7d34f verified
Raw
History Blame Contribute Delete
4.59 kB
"""Grounded spending chat agent with MiniCPM5-1B (Phase 6).
Answers questions about *your own* transactions ("How much did I spend on
groceries last month?", "What's my biggest category?"). To keep a 1B model
honest, we don't let it free-associate: we precompute the real numbers from
core.analytics, stuff them into the prompt as the ONLY source of truth, and
instruct the model to answer strictly from them.
Reuses the MiniCPM5-1B instance already loaded by core.categorize (no second
copy) and the ZeroGPU decorator from core.extract.
"""
from __future__ import annotations
from typing import Any
from core import analytics, inference
MAX_NEW_TOKENS = 256
RECENT_LIMIT = 12
MONTHS_LIMIT = 6
SYSTEM_PROMPT = (
"You are BudgetBuddy, a friendly personal-spending assistant. The user's "
"message contains their REAL spending data followed by a question. Always "
"answer using that data — quote the exact numbers from it. Never say you "
"lack access; the data is right there in the message. Be concise and warm, "
"use the user's currency, and don't output code or JSON."
)
def _fmt_money(amount: float, currency: str) -> str:
prefix = f"{currency} " if currency else ""
return f"{prefix}{amount:,.2f}"
def build_context(records: list[dict[str, Any]]) -> str:
"""Compact, factual summary of the user's data — the model's only source."""
if not records:
return "The user has no saved transactions yet."
summ = analytics.summary(records)
cur = summ["currency"]
lines: list[str] = []
lines.append(f"Currency: {cur or 'unknown'}")
lines.append(f"Total transactions saved: {summ['total_receipts']}")
lines.append(
f"This month spend: {_fmt_money(summ['this_month_total'], cur)} "
f"across {summ['receipts_this_month']} transaction(s)"
)
lines.append(f"Last month spend: {_fmt_money(summ['prev_month_total'], cur)}")
if summ["pct_change"] is not None:
lines.append(f"Change vs last month: {summ['pct_change']:+.0f}%")
if summ["top_category"]:
lines.append(f"Top category this month: {summ['top_category']}")
by_cat = analytics.spend_by_category(records)
if by_cat:
lines.append("Spend by category (all time):")
for row in by_cat:
lines.append(f" - {row['category']}: {_fmt_money(row['amount'], cur)}")
by_month = analytics.spend_over_time(records, "Monthly")
if by_month:
lines.append("Spend by month:")
for row in by_month[-MONTHS_LIMIT:]:
lines.append(f" - {row['period']}: {_fmt_money(row['amount'], cur)}")
recent = analytics.transactions_table(records)[:RECENT_LIMIT]
if recent:
lines.append(f"Most recent transactions (up to {RECENT_LIMIT}):")
for date, vendor, total, category in recent:
lines.append(
f" - {date or '?'} | {vendor or '(no name)'} | "
f"{_fmt_money(float(total), cur)} | {category}"
)
return "\n".join(lines)
def _generate(messages: list[dict[str, str]]) -> str:
return inference.text_generate(messages, max_new_tokens=MAX_NEW_TOKENS)
def answer(
question: str,
records: list[dict[str, Any]],
history: list[tuple[str, str]] | None = None,
) -> str:
"""Answer a spending question grounded in the user's records.
`history` is an optional list of prior (user, assistant) turns. Never raises.
"""
question = (question or "").strip()
if not question:
return "Ask me about your spending — e.g. “How much did I spend on Dining this month?”"
context = build_context(records)
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
for user_turn, bot_turn in (history or [])[-3:]:
if user_turn:
messages.append({"role": "user", "content": str(user_turn)})
if bot_turn:
messages.append({"role": "assistant", "content": str(bot_turn)})
# Data sits in the user turn, right next to the question — small models
# attend to it far more reliably there than in the system prompt.
messages.append({
"role": "user",
"content": f"Here is my spending data:\n{context}\n\nQuestion: {question}",
})
for attempt in range(2):
try:
out = _generate(messages)
if out:
return out
except Exception as e: # pragma: no cover - model/runtime dependent
print(f"[chat] generation failed (attempt {attempt + 1}): {e}")
return "Sorry — I couldn't answer that right now. Please try again."