Spaces:
Running
Running
| """ | |
| 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, | |
| } | |