Files
Personal-Finance-Management/app/services/ai_service.py
T
2026-05-31 16:49:18 -04:00

359 lines
14 KiB
Python

"""
AI Service — Groq API integration with financial context injection.
Builds a anonymized summary of the user's finances and sends to Groq.
No account names or personal details are sent — only aggregated numbers.
"""
import logging
import json
from datetime import date, timedelta
from flask import current_app
from sqlalchemy import func
from app.extensions import db
from app.models.transaction import Transaction
from app.models.category import Category
from app.models.account import Account
from app.models.budget import Budget
from app.models.goal import Goal
from app.models.investment import Investment
from app.models.ai_insight import AiInsight
from app.services.budget_service import get_total_spent, get_total_budget
log = logging.getLogger(__name__)
# ── Context builder ───────────────────────────────────────────────────────────
def build_context(days=90):
"""
Build an anonymized financial context string to inject into the AI prompt.
Covers last `days` days of activity.
"""
today = date.today()
since = today - timedelta(days=days)
curr_month = today.strftime('%Y-%m')
currency = current_app.config.get('APP_CURRENCY', 'USD')
symbol = current_app.config.get('APP_CURRENCY_SYMBOL', '$')
lines = [f"Financial data summary (currency: {currency}):"]
# ── This month summary ────────────────────────────────────────────────────
month_income = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'income',
Transaction.date >= date(today.year, today.month, 1),
Transaction.date <= today,
).scalar()
month_expense = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.transaction_type == 'expense',
Transaction.date >= date(today.year, today.month, 1),
Transaction.date <= today,
).scalar()
lines.append(f"\nCURRENT MONTH ({today.strftime('%B %Y')}):")
lines.append(f" Income: {symbol}{float(month_income):,.2f}")
lines.append(f" Expenses: {symbol}{float(month_expense):,.2f}")
lines.append(f" Net: {symbol}{float(month_income) - float(month_expense):,.2f}")
# ── Top spending categories (this month) ──────────────────────────────────
top_cats = db.session.query(
Category.name,
func.sum(Transaction.amount).label('total')
).join(Transaction, Transaction.category_id == Category.id)\
.filter(
Transaction.transaction_type == 'expense',
Transaction.date >= date(today.year, today.month, 1),
Transaction.date <= today,
).group_by(Category.id)\
.order_by(func.sum(Transaction.amount).desc())\
.limit(6).all()
if top_cats:
lines.append("\nTOP SPENDING CATEGORIES (this month):")
for cat, total in top_cats:
lines.append(f" {cat}: {symbol}{float(total):,.2f}")
# ── Budget status ─────────────────────────────────────────────────────────
from app.services.budget_service import get_budget_summary
budget_summary = get_budget_summary(curr_month)
over_budget = [b for b in budget_summary if b['is_over']]
near_budget = [b for b in budget_summary if b['has_budget'] and b['pct'] and b['pct'] >= 80 and not b['is_over']]
total_budget = get_total_budget(curr_month)
total_spent_month = get_total_spent(curr_month)
if total_budget > 0:
lines.append(f"\nBUDGET STATUS ({today.strftime('%B %Y')}):")
lines.append(f" Total budget: {symbol}{total_budget:,.2f}")
lines.append(f" Total spent: {symbol}{total_spent_month:,.2f} ({round(total_spent_month/total_budget*100,1)}%)")
if over_budget:
lines.append(f" OVER BUDGET: {', '.join(b['category'].name for b in over_budget)}")
if near_budget:
lines.append(f" Near limit (80%+): {', '.join(b['category'].name + ' ' + str(b['pct']) + '%' for b in near_budget)}")
# ── Accounts net worth ────────────────────────────────────────────────────
accounts = Account.query.filter_by(is_active=True).all()
total_assets = sum(float(a.balance) for a in accounts if float(a.balance) > 0 and a.account_type != 'credit_card')
total_liab = sum(abs(float(a.balance)) for a in accounts if float(a.balance) < 0)
net_worth = total_assets - total_liab
lines.append(f"\nNET WORTH: {symbol}{net_worth:,.2f}")
lines.append(f" Assets: {symbol}{total_assets:,.2f}")
lines.append(f" Liabilities: {symbol}{total_liab:,.2f}")
# ── Goals ────────────────────────────────────────────────────────────────
active_goals = Goal.query.filter_by(is_completed=False).all()
if active_goals:
lines.append("\nACTIVE SAVINGS GOALS:")
for g in active_goals:
lines.append(
f" {g.name}: {symbol}{float(g.current_amount):,.2f} / {symbol}{float(g.target_amount):,.2f} "
f"({g.progress_percent}%)"
+ (f" — target {g.target_date.strftime('%b %Y')}" if g.target_date else "")
)
# ── Investments ───────────────────────────────────────────────────────────
investments = Investment.query.filter_by(is_active=True).all()
if investments:
total_inv_value = sum(i.current_value for i in investments)
total_inv_gain = sum(i.unrealized_gain for i in investments)
lines.append(f"\nINVESTMENT PORTFOLIO:")
lines.append(f" Total value: {symbol}{total_inv_value:,.2f}")
lines.append(f" Unrealized P&L: {symbol}{total_inv_gain:,.2f}")
# ── Recent 20 transactions ────────────────────────────────────────────────
recent = Transaction.query\
.filter(
Transaction.transaction_type.in_(['income', 'expense']),
Transaction.date >= since,
)\
.order_by(Transaction.date.desc())\
.limit(20).all()
if recent:
lines.append(f"\nRECENT TRANSACTIONS (last {days} days, newest first):")
for txn in recent:
cat = txn.category.name if txn.category else 'Uncategorized'
sign = '+' if txn.transaction_type == 'income' else '-'
lines.append(
f" {txn.date.strftime('%b %d')} | {cat} | {sign}{symbol}{float(txn.amount):,.2f} | {txn.description}"
)
return '\n'.join(lines)
# ── Groq chat (streaming) ─────────────────────────────────────────────────────
def stream_chat(user_message, context=None):
"""
Generator that yields SSE-formatted chunks from Groq streaming API.
Yields: 'data: <chunk>\n\n' or 'data: [DONE]\n\n'
"""
api_key = current_app.config.get('GROQ_API_KEY', '')
model = current_app.config.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
if not api_key:
yield 'data: AI assistant is not configured. Please set GROQ_API_KEY in your .env file.\n\n'
yield 'data: [DONE]\n\n'
return
if context is None:
try:
context = build_context()
except Exception as e:
log.error(f'[ai] context build failed: {e}')
context = '(financial context unavailable)'
system_prompt = (
"You are a concise, helpful personal finance assistant. "
"Answer questions based on the financial data provided. "
"Be specific with numbers. Keep answers focused and practical. "
"Do not make up data that isn't in the context. "
"If something isn't in the data, say so briefly."
)
messages = [
{"role": "system", "content": system_prompt + "\n\n" + context},
{"role": "user", "content": user_message},
]
try:
import requests
resp = requests.post(
'https://api.groq.com/openai/v1/chat/completions',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
},
json={
'model': model,
'messages': messages,
'max_tokens': 1000,
'stream': True,
},
stream=True,
timeout=60,
)
resp.raise_for_status()
full_text = []
for line in resp.iter_lines():
if not line:
continue
line = line.decode('utf-8')
if not line.startswith('data: '):
continue
data = line[6:]
if data == '[DONE]':
break
try:
chunk = json.loads(data)
delta = chunk['choices'][0]['delta'].get('content', '')
if delta:
full_text.append(delta)
# Escape newlines for SSE
safe = delta.replace('\n', '<br>')
yield f'data: {safe}\n\n'
except (json.JSONDecodeError, KeyError, IndexError):
continue
# Store in DB
if full_text:
_save_insight(
content=''.join(full_text),
insight_type='chat_response',
prompt_summary=user_message[:500],
model=model,
)
yield 'data: [DONE]\n\n'
except requests.exceptions.Timeout:
yield 'data: Request timed out. Please try again.\n\n'
yield 'data: [DONE]\n\n'
except requests.exceptions.HTTPError as e:
if e.response.status_code == 429:
yield 'data: Rate limit reached. Please wait a moment and try again.\n\n'
elif e.response.status_code == 401:
yield 'data: Invalid Groq API key. Please check your .env configuration.\n\n'
else:
yield f'data: API error ({e.response.status_code}). Please try again.\n\n'
yield 'data: [DONE]\n\n'
except Exception as e:
log.error(f'[ai] stream error: {e}')
yield 'data: AI assistant is temporarily unavailable.\n\n'
yield 'data: [DONE]\n\n'
# ── Daily insight (non-streaming) ─────────────────────────────────────────────
def generate_daily_insight():
"""
Generate and store a daily summary insight (called by cron script).
Returns the insight text or None on failure.
"""
api_key = current_app.config.get('GROQ_API_KEY', '')
model = current_app.config.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
if not api_key:
log.warning('[ai] GROQ_API_KEY not set, skipping daily insight')
return None
today = date.today()
# Don't regenerate if already done today
existing = AiInsight.query.filter_by(
insight_date=today,
insight_type='daily_summary'
).first()
if existing:
return existing.content
try:
context = build_context(days=30)
except Exception as e:
log.error(f'[ai] context build failed: {e}')
return None
prompt = (
"Based on this month's financial data, give me a brief daily summary "
"(3-5 sentences). Cover: spending vs income, any budget alerts, "
"and one actionable tip. Be specific with numbers."
)
try:
import requests
resp = requests.post(
'https://api.groq.com/openai/v1/chat/completions',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
},
json={
'model': model,
'messages': [
{"role": "system", "content": "You are a concise personal finance assistant."},
{"role": "system", "content": context},
{"role": "user", "content": prompt},
],
'max_tokens': 400,
'stream': False,
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
content = data['choices'][0]['message']['content']
tokens = data.get('usage', {}).get('total_tokens')
_save_insight(
content=content,
insight_type='daily_summary',
prompt_summary='Daily auto-summary',
model=model,
tokens=tokens,
)
return content
except Exception as e:
log.error(f'[ai] daily insight failed: {e}')
return None
# ── Helper ────────────────────────────────────────────────────────────────────
def _save_insight(content, insight_type, prompt_summary=None, model=None, tokens=None):
try:
insight = AiInsight(
insight_date=date.today(),
insight_type=insight_type,
content=content,
prompt_summary=prompt_summary,
model_used=model,
tokens_used=tokens,
)
db.session.add(insight)
db.session.commit()
except Exception as e:
db.session.rollback()
log.error(f'[ai] save insight failed: {e}')
def get_latest_daily_insight():
"""Return today's daily insight if it exists, else the most recent one."""
today = date.today()
insight = AiInsight.query.filter_by(
insight_date=today,
insight_type='daily_summary'
).first()
if not insight:
insight = AiInsight.query\
.filter_by(insight_type='daily_summary')\
.order_by(AiInsight.insight_date.desc())\
.first()
return insight