Bankbot / backend /app /ai /intelligence.py
mohsin-devs's picture
feat: AI Coach Mode, Fraud Explanations, Monthly Narrative β€” 3 new intelligence features
c004697
Raw
History Blame Contribute Delete
19.5 kB
"""
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,
}