""" AI Intelligence Engine — three high-value features: 1. Financial Coach Mode — proactive weekly recommendations, nudges, anomaly explanations 2. Fraud Explanation — human-readable AI explanation of why a transaction was flagged 3. Spending Narrative — monthly story: what changed, what improved, what to watch """ import os from datetime import datetime, timedelta from collections import defaultdict from sqlalchemy.orm import Session from app.database.models import ( User, Account, Transaction, Goal, Investment, Subscription, FraudLog, Notification ) from app.ai.coaching import calculate_financial_health_score from app.ai.forecasting import get_cashflow_metrics # ─── Shared Groq/OpenAI caller ──────────────────────────────────────────────── def _call_llm(messages: list, max_tokens: int = 500) -> str | None: """ Calls OpenAI → Groq in priority order with the exact messages list provided. Returns None if both fail. """ openai_key = os.environ.get("OPENAI_API_KEY", "") groq_key = os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", "") if openai_key: try: from openai import OpenAI client = OpenAI(api_key=openai_key) res = client.chat.completions.create( model="gpt-4o-mini", messages=messages, temperature=0.2, max_tokens=max_tokens, ) return res.choices[0].message.content.strip() except Exception as e: print(f"[intelligence] OpenAI error: {e}") if groq_key: try: from groq import Groq client = Groq(api_key=groq_key) res = client.chat.completions.create( model="llama-3.3-70b-versatile", messages=messages, temperature=0.2, max_tokens=max_tokens, ) return res.choices[0].message.content.strip() except Exception as e: print(f"[intelligence] Groq error: {e}") return None # ─── 1. FINANCIAL COACH MODE ────────────────────────────────────────────────── def generate_weekly_coaching(db: Session, user_id: str) -> dict: """ Generates a proactive weekly coaching report with: - Weekly summary (what happened this week) - Budget coaching (where money went vs targets) - Savings nudge (specific, actionable) - Anomaly explanations (unusual patterns this week) - Top 3 recommendations """ user = db.query(User).filter(User.id == user_id).first() if not user: return {"error": "User not found"} # ── Gather this week's data ─────────────────────────────────────────────── now = datetime.utcnow() week_start = now - timedelta(days=7) accounts = db.query(Account).filter(Account.user_id == user_id).all() account_ids = [a.id for a in accounts] total_balance = sum(a.balance for a in accounts) savings_balance = sum(a.balance for a in accounts if a.type == "savings") week_txns = ( db.query(Transaction) .filter( Transaction.account_id.in_(account_ids), Transaction.timestamp >= week_start, ) .all() ) if account_ids else [] week_spend = sum(t.amount for t in week_txns if t.type == "debit") week_income = sum(t.amount for t in week_txns if t.type == "credit") # Category breakdown this week cat_spend: dict = defaultdict(float) for t in week_txns: if t.type == "debit": cat_spend[t.category or "Other"] += t.amount top_cats = sorted(cat_spend.items(), key=lambda x: x[1], reverse=True)[:5] # Compare to prior week prior_start = week_start - timedelta(days=7) prior_txns = ( db.query(Transaction) .filter( Transaction.account_id.in_(account_ids), Transaction.timestamp >= prior_start, Transaction.timestamp < week_start, ) .all() ) if account_ids else [] prior_spend = sum(t.amount for t in prior_txns if t.type == "debit") spend_delta_pct = ((week_spend - prior_spend) / max(prior_spend, 1)) * 100 # Goals progress goals = db.query(Goal).filter(Goal.user_id == user_id).all() goals_summary = [ f"{g.title}: ${g.current_amount:,.0f}/${g.target_amount:,.0f} " f"({g.current_amount/max(g.target_amount,1)*100:.0f}%)" for g in goals ] # Health score score_data = calculate_financial_health_score(db, user_id) health_score = score_data.get("overall_score", 50) improvements = score_data.get("actionable_improvements", []) # Anomalies this week anomalies = [] if week_txns: all_amounts = [t.amount for t in week_txns if t.type == "debit"] if all_amounts: import numpy as np avg = float(np.mean(all_amounts)) for t in week_txns: if t.type == "debit" and t.amount > avg * 3: anomalies.append( f"${t.amount:,.2f} at {t.merchant or 'Unknown'} " f"({(t.amount/avg):.1f}× your weekly average)" ) # Late-night count late_night = [ t for t in week_txns if t.type == "debit" and (t.timestamp.hour >= 23 or t.timestamp.hour < 4) ] # ── Build LLM prompt ────────────────────────────────────────────────────── system = ( "You are a personal AI financial coach. You have access to the user's real financial data. " "Be specific, use exact numbers, and give actionable advice. " "Never be generic. Format your response with clear sections." ) user_prompt = f"""Generate a weekly financial coaching report for {user.profile_data.get('name', 'the user')}. FINANCIAL DATA: - Total Balance: ${total_balance:,.2f} (Savings: ${savings_balance:,.2f}) - Health Score: {health_score:.0f}/100 - This Week Spending: ${week_spend:,.2f} ({spend_delta_pct:+.1f}% vs last week) - This Week Income: ${week_income:,.2f} - Top Spending Categories: {', '.join(f'{c}: ${a:,.0f}' for c, a in top_cats)} - Goals: {'; '.join(goals_summary) if goals_summary else 'None set'} - Anomalies: {'; '.join(anomalies) if anomalies else 'None detected'} - Late-night transactions: {len(late_night)} - Key improvements needed: {'; '.join(improvements[:2]) if improvements else 'None'} Write a coaching report with these exact sections: 1. WEEKLY SUMMARY (2 sentences, use exact dollar figures) 2. BUDGET COACHING (what's on track, what's overspent — use real category names and amounts) 3. SAVINGS NUDGE (one specific, actionable savings recommendation with a dollar target) 4. ANOMALY ALERT (explain any unusual spending in plain English, or say "No anomalies this week") 5. TOP 3 ACTIONS (numbered list, each under 15 words, specific and actionable) Be direct. No filler phrases.""" messages = [ {"role": "system", "content": system}, {"role": "user", "content": user_prompt}, ] coaching_text = _call_llm(messages, max_tokens=600) if not coaching_text: # Rule-based fallback direction = "up" if spend_delta_pct > 0 else "down" coaching_text = ( f"WEEKLY SUMMARY\nYou spent ${week_spend:,.2f} this week, " f"{abs(spend_delta_pct):.0f}% {direction} from last week. " f"Your balance stands at ${total_balance:,.2f}.\n\n" f"BUDGET COACHING\nTop category: {top_cats[0][0] if top_cats else 'N/A'} " f"(${top_cats[0][1]:,.2f} if top_cats else '$0').\n\n" f"SAVINGS NUDGE\nTransfer ${min(week_income * 0.1, 200):,.0f} to savings this week.\n\n" f"ANOMALY ALERT\n{'Unusual: ' + anomalies[0] if anomalies else 'No anomalies this week.'}\n\n" f"TOP 3 ACTIONS\n" + "\n".join(f"{i+1}. {imp}" for i, imp in enumerate(improvements[:3])) ) return { "generated_at": now.isoformat(), "week_start": week_start.date().isoformat(), "week_end": now.date().isoformat(), "health_score": health_score, "week_spend": round(week_spend, 2), "week_income": round(week_income, 2), "spend_delta_pct": round(spend_delta_pct, 1), "top_categories": [{"name": c, "amount": round(a, 2)} for c, a in top_cats], "anomalies": anomalies, "coaching_report": coaching_text, "improvements": improvements[:3], } # ─── 2. AI FRAUD EXPLANATION ────────────────────────────────────────────────── def explain_fraud_alert(db: Session, fraud_log_id: str) -> dict: """ Generates a human-readable AI explanation of exactly why a transaction was flagged, with context about the user's normal patterns. """ fraud_log = db.query(FraudLog).filter(FraudLog.id == fraud_log_id).first() if not fraud_log: return {"error": "Fraud log not found"} txn = fraud_log.transaction if not txn: return {"error": "Transaction not found"} account = db.query(Account).filter(Account.id == txn.account_id).first() if not account: return {"error": "Account not found"} user_id = account.user_id # Get user's historical baseline history = ( db.query(Transaction) .filter( Transaction.account_id == txn.account_id, Transaction.type == "debit", Transaction.id != txn.id, ) .order_by(Transaction.timestamp.desc()) .limit(30) .all() ) import numpy as np avg_amount = float(np.mean([t.amount for t in history])) if history else 0 typical_hour = "daytime" if txn.timestamp.hour >= 23 or txn.timestamp.hour < 4: typical_hour = f"{txn.timestamp.strftime('%I:%M %p')} (late night)" else: typical_hour = txn.timestamp.strftime("%I:%M %p") risk_pct = round(fraud_log.risk_score * 100) raw_reasons = fraud_log.suspicious_activity_details or "" system = ( "You are a fraud analyst AI. Explain in plain English why this transaction was flagged. " "Be specific, cite exact numbers, and help the user understand the risk. " "Write 2-3 sentences maximum. Do not use bullet points." ) user_prompt = f"""Explain why this transaction was flagged as suspicious: Transaction: ${txn.amount:,.2f} at {txn.merchant or 'Unknown merchant'} Time: {typical_hour} Risk Score: {risk_pct}/100 Detection reasons: {raw_reasons} User's average transaction: ${avg_amount:,.2f} Amount vs average: {txn.amount/max(avg_amount,1):.1f}× Write a clear, human-readable explanation of why this was flagged. Start with "This payment was flagged because..." and be specific.""" messages = [ {"role": "system", "content": system}, {"role": "user", "content": user_prompt}, ] explanation = _call_llm(messages, max_tokens=150) if not explanation: # Rule-based fallback parts = [] if txn.amount > avg_amount * 2: parts.append( f"the amount (${txn.amount:,.2f}) is {txn.amount/max(avg_amount,1):.1f}× " f"your typical transaction of ${avg_amount:,.2f}" ) if txn.timestamp.hour >= 23 or txn.timestamp.hour < 4: parts.append(f"it occurred at {txn.timestamp.strftime('%I:%M %p')} (unusual hours)") if not parts: parts = [raw_reasons or "multiple anomaly signals were detected simultaneously"] explanation = f"This payment was flagged because {' and '.join(parts)}." return { "fraud_log_id": fraud_log_id, "transaction_id": txn.id, "merchant": txn.merchant, "amount": txn.amount, "timestamp": txn.timestamp.isoformat(), "risk_score": risk_pct, "raw_reasons": raw_reasons.split("; ") if raw_reasons else [], "ai_explanation": explanation, "user_avg_amount": round(avg_amount, 2), "amount_vs_avg": round(txn.amount / max(avg_amount, 1), 1), } # ─── 3. SPENDING NARRATIVE ──────────────────────────────────────────────────── def generate_spending_narrative(db: Session, user_id: str) -> dict: """ Generates a monthly spending narrative: - What changed vs last month (category-level) - Investment performance insight - Savings performance - Subscription waste analysis - One-paragraph human story of the month """ user = db.query(User).filter(User.id == user_id).first() if not user: return {"error": "User not found"} now = datetime.utcnow() month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) last_month_start = (month_start - timedelta(days=1)).replace(day=1) accounts = db.query(Account).filter(Account.user_id == user_id).all() account_ids = [a.id for a in accounts] def get_category_spend(start, end): txns = ( db.query(Transaction) .filter( Transaction.account_id.in_(account_ids), Transaction.type == "debit", Transaction.timestamp >= start, Transaction.timestamp < end, ) .all() ) if account_ids else [] cats: dict = defaultdict(float) for t in txns: cats[t.category or "Other"] += t.amount return dict(cats), txns this_cats, this_txns = get_category_spend(month_start, now) last_cats, last_txns = get_category_spend(last_month_start, month_start) this_total = sum(this_cats.values()) last_total = sum(last_cats.values()) total_delta_pct = ((this_total - last_total) / max(last_total, 1)) * 100 # Category changes all_cats = set(this_cats) | set(last_cats) cat_changes = [] for cat in all_cats: this_amt = this_cats.get(cat, 0) last_amt = last_cats.get(cat, 0) if last_amt > 0: delta_pct = ((this_amt - last_amt) / last_amt) * 100 cat_changes.append({ "category": cat, "this_month": round(this_amt, 2), "last_month": round(last_amt, 2), "delta_pct": round(delta_pct, 1), }) cat_changes.sort(key=lambda x: abs(x["delta_pct"]), reverse=True) # Investment performance investments = db.query(Investment).filter(Investment.user_id == user_id).all() inv_total_invested = sum(i.amount_invested for i in investments) inv_total_value = sum(i.current_value for i in investments) inv_gain = inv_total_value - inv_total_invested inv_gain_pct = (inv_gain / max(inv_total_invested, 1)) * 100 # Savings savings_acct = next((a for a in accounts if a.type == "savings"), None) savings_balance = savings_acct.balance if savings_acct else 0 # Subscriptions subs = db.query(Subscription).filter( Subscription.user_id == user_id, Subscription.active == True ).all() monthly_sub_cost = sum( s.amount if s.billing_cycle == "monthly" else s.amount / 12 for s in subs ) # Income this month this_income = sum( t.amount for t in this_txns if t.type == "credit" ) if this_txns else 0 # Also check credit transactions income_txns = ( db.query(Transaction) .filter( Transaction.account_id.in_(account_ids), Transaction.type == "credit", Transaction.timestamp >= month_start, ) .all() ) if account_ids else [] this_income = sum(t.amount for t in income_txns) # ── Build narrative prompt ──────────────────────────────────────────────── top_changes = cat_changes[:4] changes_text = "\n".join( f" - {c['category']}: ${c['this_month']:,.0f} this month vs ${c['last_month']:,.0f} last month " f"({c['delta_pct']:+.0f}%)" for c in top_changes ) system = ( "You are a personal finance narrator. Write in a warm, intelligent, first-person style " "as if you're a financial advisor summarizing the month for your client. " "Use exact numbers. Be insightful, not generic." ) user_prompt = f"""Write a monthly financial narrative for {user.profile_data.get('name', 'the user')}. THIS MONTH'S DATA: - Total Spending: ${this_total:,.2f} ({total_delta_pct:+.1f}% vs last month) - Total Income: ${this_income:,.2f} - Savings Balance: ${savings_balance:,.2f} - Investment Portfolio: ${inv_total_value:,.2f} ({inv_gain_pct:+.1f}% return) - Monthly Subscriptions: ${monthly_sub_cost:,.2f}/month ({len(subs)} active) CATEGORY CHANGES: {changes_text if changes_text else ' - No prior month data available'} Write 4 short sections: 1. MONTH IN REVIEW (1-2 sentences: overall spending story, use exact figures) 2. WHAT IMPROVED (1 sentence: best positive change with exact numbers) 3. WATCH OUT (1 sentence: biggest concern or overspend with exact numbers) 4. NEXT MONTH GOAL (1 sentence: one specific, measurable target) Keep each section to 1-2 sentences. Use real numbers throughout.""" messages = [ {"role": "system", "content": system}, {"role": "user", "content": user_prompt}, ] narrative = _call_llm(messages, max_tokens=400) if not narrative: direction = "increased" if total_delta_pct > 0 else "decreased" best_cat = min(cat_changes, key=lambda x: x["delta_pct"], default=None) worst_cat = max(cat_changes, key=lambda x: x["delta_pct"], default=None) narrative = ( f"MONTH IN REVIEW\nYour spending {direction} by {abs(total_delta_pct):.0f}% " f"to ${this_total:,.2f} this month.\n\n" f"WHAT IMPROVED\n" + (f"{best_cat['category']} spending dropped {abs(best_cat['delta_pct']):.0f}%." if best_cat and best_cat['delta_pct'] < 0 else "Spending patterns are stable.") + f"\n\nWATCH OUT\n" + (f"{worst_cat['category']} increased {worst_cat['delta_pct']:.0f}% — review this category." if worst_cat and worst_cat['delta_pct'] > 10 else "No major overspends detected.") + f"\n\nNEXT MONTH GOAL\nAim to keep total spending under ${this_total * 0.95:,.0f}." ) return { "month": month_start.strftime("%B %Y"), "generated_at": now.isoformat(), "summary": { "total_spend": round(this_total, 2), "total_income": round(this_income, 2), "spend_delta_pct": round(total_delta_pct, 1), "savings_balance": round(savings_balance, 2), "investment_value": round(inv_total_value, 2), "investment_gain_pct": round(inv_gain_pct, 1), "monthly_subscriptions": round(monthly_sub_cost, 2), }, "category_changes": cat_changes[:6], "narrative": narrative, }