361 lines
14 KiB
Python
361 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 sqlalchemy.orm import joinedload
|
|
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\
|
|
.options(joinedload(Transaction.category))\
|
|
.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
|