""" 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: \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', '
') 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