File size: 19,530 Bytes
c004697
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
"""
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,
    }