MJ-Prod commited on
Commit
bce302b
·
1 Parent(s): a40ff66
Files changed (2) hide show
  1. app.py +318 -1
  2. plaid_client.py +101 -1
app.py CHANGED
@@ -1183,4 +1183,321 @@ async def trial_yearsphere_data(request: Request):
1183
  return {
1184
  "months": months_out,
1185
  "current_month_index": current_idx,
1186
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1183
  return {
1184
  "months": months_out,
1185
  "current_month_index": current_idx,
1186
+ }
1187
+
1188
+
1189
+
1190
+
1191
+ @app.post("/csv/chart_data")
1192
+ async def csv_chart_data(request: Request):
1193
+ """Chart data from user-uploaded CSV transactions."""
1194
+ from plaid_client import get_chart_data_from_csv
1195
+ body = await request.json()
1196
+ transactions = body.get("transactions", [])
1197
+ return get_chart_data_from_csv(transactions)
1198
+
1199
+
1200
+ @app.post("/csv/recurring")
1201
+ async def csv_recurring(request: Request):
1202
+ """Recurring pattern detection from user-uploaded CSV transactions."""
1203
+ from plaid_client import get_recurring_from_csv
1204
+ body = await request.json()
1205
+ transactions = body.get("transactions", [])
1206
+ return get_recurring_from_csv(transactions)
1207
+
1208
+
1209
+ @app.post("/csv/chat/stream")
1210
+ async def csv_chat_stream(request: Request):
1211
+ """Chat with AI using CSV-uploaded transactions as context."""
1212
+ from plaid_client import get_snapshot_from_csv
1213
+
1214
+ body = await request.json()
1215
+ message = body.get("message", "")
1216
+ transactions = body.get("transactions", [])
1217
+ csv_user_id = f"csv-{request.client.host}"
1218
+
1219
+ financial_context = get_snapshot_from_csv(transactions)
1220
+
1221
+ def generate():
1222
+ for token in stream_answer(message, csv_user_id, financial_context=financial_context):
1223
+ yield f"data: {json.dumps({'chunk': token})}\n\n"
1224
+ yield "data: [DONE]\n\n"
1225
+
1226
+ return StreamingResponse(generate(), media_type="text/event-stream")
1227
+
1228
+
1229
+ @app.post("/csv/reset")
1230
+ async def csv_reset(request: Request):
1231
+ """Reset chat history for CSV mode."""
1232
+ csv_user_id = f"csv-{request.client.host}"
1233
+ reset_history(csv_user_id)
1234
+ return {"status": "reset"}
1235
+
1236
+
1237
+ @app.post("/csv/prescription/diagnose")
1238
+ async def csv_prescription_diagnose(request: Request):
1239
+ """Streaming diagnosis using CSV transaction context."""
1240
+ from plaid_client import get_snapshot_from_csv
1241
+
1242
+ body = await request.json()
1243
+ prescription_type = body.get("type", "account")
1244
+ prescription_name = body.get("name", "")
1245
+ prescription_amount = body.get("amount", 0)
1246
+ prescription_details = body.get("details", "")
1247
+ follow_up_question = body.get("question", "")
1248
+ transactions = body.get("transactions", [])
1249
+
1250
+ csv_user_id = f"csv-{request.client.host}"
1251
+ financial_context = get_snapshot_from_csv(transactions)
1252
+
1253
+ if follow_up_question:
1254
+ prompt = f"""The user previously asked you to diagnose their {prescription_type}: "{prescription_name}" ({prescription_details}, currently ${prescription_amount:.2f}).
1255
+
1256
+ They're now asking a follow-up question: "{follow_up_question}"
1257
+
1258
+ Answer their question directly, keeping the same calm, doctor-patient tone. Reference the specific prescription details where relevant."""
1259
+ else:
1260
+ prompt = f"""The user just handed you their {prescription_type} for a check-up: "{prescription_name}" ({prescription_details}, currently ${prescription_amount:.2f}).
1261
+
1262
+ Give a warm, doctor-patient style diagnosis that covers:
1263
+
1264
+ 1. **What it is** — a plain-language description of this {prescription_type}
1265
+ 2. **The amount** — acknowledge the current amount without judgment
1266
+ 3. **How it looks** — is this amount healthy, concerning, or somewhere in between? Be honest but calm.
1267
+ 4. **Recent history** — mention what you can observe from their recent activity
1268
+ 5. **What could go better** — 1-2 specific, actionable suggestions (only if genuinely useful)
1269
+ 6. **Reassurance** — even if there's room to improve, name what's going right. If the account is in good shape, celebrate it warmly.
1270
+
1271
+ Keep the tone calm, non-judgmental, and specific to this {prescription_type}. Don't lecture. Don't use words like "leak" or "spike." Speak like a doctor who cares about the patient, not a financial advisor trying to upsell them.
1272
+
1273
+ Under 200 words. Use markdown headers (**bold**) for each section. Keep sections short — 1-2 sentences each.
1274
+
1275
+ CRITICAL: Respond with prose only. Do NOT include [CHART_DATA]...[/CHART_DATA] blocks, JSON, code fences, or any structured data."""
1276
+
1277
+ def generate():
1278
+ for token in stream_answer(prompt, csv_user_id, financial_context=financial_context):
1279
+ yield f"data: {json.dumps({'chunk': token})}\n\n"
1280
+ yield "data: [DONE]\n\n"
1281
+
1282
+ return StreamingResponse(generate(), media_type="text/event-stream")
1283
+
1284
+
1285
+ @app.post("/csv/prescription/activity")
1286
+ async def csv_prescription_activity(request: Request):
1287
+ """Activity data for the pulse chart from CSV transactions."""
1288
+ body = await request.json()
1289
+ transactions = body.get("transactions", [])
1290
+
1291
+ # Reuse the same activity aggregation logic as trial mode
1292
+ # Group transactions by day, count events
1293
+ from collections import defaultdict
1294
+ from datetime import date, timedelta
1295
+
1296
+ end_date = date.today()
1297
+ start_date = end_date - timedelta(days=90)
1298
+
1299
+ daily_activity = defaultdict(lambda: {'count': 0, 'total_expense': 0, 'total_income': 0})
1300
+
1301
+ for txn in transactions:
1302
+ txn_date_str = txn.get('date', '')
1303
+ try:
1304
+ txn_date = date.fromisoformat(txn_date_str)
1305
+ except (ValueError, TypeError):
1306
+ continue
1307
+
1308
+ if txn_date < start_date or txn_date > end_date:
1309
+ continue
1310
+
1311
+ amount = txn.get('amount', 0)
1312
+ category = txn.get('personal_finance_category', {}).get('primary', '')
1313
+
1314
+ # Skip transfers to avoid double-counting
1315
+ if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
1316
+ continue
1317
+
1318
+ key = txn_date.isoformat()
1319
+ daily_activity[key]['count'] += 1
1320
+ if amount > 0:
1321
+ daily_activity[key]['total_expense'] += amount
1322
+ else:
1323
+ daily_activity[key]['total_income'] += abs(amount)
1324
+
1325
+ # Build activity array
1326
+ activity = []
1327
+ current = start_date
1328
+ while current <= end_date:
1329
+ key = current.isoformat()
1330
+ data = daily_activity.get(key, {'count': 0, 'total_expense': 0, 'total_income': 0})
1331
+ activity.append({
1332
+ 'date': key,
1333
+ 'value': round(data['total_expense'] + data['total_income'], 2),
1334
+ 'count': data['count'],
1335
+ 'is_income': data['total_income'] > data['total_expense'],
1336
+ })
1337
+ current += timedelta(days=1)
1338
+
1339
+ return {
1340
+ 'activity': activity,
1341
+ 'total_events': sum(a['count'] for a in activity),
1342
+ }
1343
+
1344
+
1345
+ @app.post("/csv/cashflow/data")
1346
+ async def csv_cashflow_data(request: Request):
1347
+ """Cash flow bubble data from CSV transactions."""
1348
+ body = await request.json()
1349
+ transactions = body.get("transactions", [])
1350
+
1351
+ from collections import defaultdict
1352
+ from datetime import date, timedelta
1353
+
1354
+ end_date = date.today()
1355
+ start_date = end_date - timedelta(days=30)
1356
+
1357
+ # Aggregate by category
1358
+ category_totals = defaultdict(lambda: {'total': 0, 'count': 0, 'merchants': defaultdict(float)})
1359
+ income_total = 0
1360
+ expense_total = 0
1361
+
1362
+ for txn in transactions:
1363
+ txn_date_str = txn.get('date', '')
1364
+ try:
1365
+ txn_date = date.fromisoformat(txn_date_str)
1366
+ except (ValueError, TypeError):
1367
+ continue
1368
+
1369
+ if txn_date < start_date or txn_date > end_date:
1370
+ continue
1371
+
1372
+ amount = txn.get('amount', 0)
1373
+ name = txn.get('name', 'Unknown')
1374
+ category = txn.get('personal_finance_category', {}).get('primary', 'GENERAL_MERCHANDISE')
1375
+
1376
+ # Skip transfers
1377
+ if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
1378
+ continue
1379
+
1380
+ if amount < 0:
1381
+ income_total += abs(amount)
1382
+ else:
1383
+ expense_total += amount
1384
+ category_totals[category]['total'] += amount
1385
+ category_totals[category]['count'] += 1
1386
+ category_totals[category]['merchants'][name] += amount
1387
+
1388
+ # Build categories array with merchants
1389
+ categories = []
1390
+ for cat, data in category_totals.items():
1391
+ merchants = [
1392
+ {'name': m_name, 'amount': round(m_amount, 2)}
1393
+ for m_name, m_amount in sorted(data['merchants'].items(), key=lambda x: -x[1])[:10]
1394
+ ]
1395
+ categories.append({
1396
+ 'category': cat,
1397
+ 'total': round(data['total'], 2),
1398
+ 'count': data['count'],
1399
+ 'merchants': merchants,
1400
+ })
1401
+
1402
+ categories.sort(key=lambda x: -x['total'])
1403
+
1404
+ return {
1405
+ 'categories': categories,
1406
+ 'income_total': round(income_total, 2),
1407
+ 'expense_total': round(expense_total, 2),
1408
+ 'remaining': round(income_total - expense_total, 2),
1409
+ }
1410
+
1411
+
1412
+ @app.post("/csv/cashflow/opinion")
1413
+ async def csv_cashflow_opinion(request: Request):
1414
+ """AI opinion on cash flow, using CSV transaction context."""
1415
+ from plaid_client import get_snapshot_from_csv
1416
+
1417
+ body = await request.json()
1418
+ transactions = body.get("transactions", [])
1419
+ category_focus = body.get("category", None)
1420
+
1421
+ csv_user_id = f"csv-{request.client.host}"
1422
+ financial_context = get_snapshot_from_csv(transactions)
1423
+
1424
+ if category_focus:
1425
+ prompt = f"""The user is looking at their spending in the "{category_focus}" category. Give them a brief, warm perspective on it.
1426
+
1427
+ Focus on:
1428
+ - What patterns you see in this category
1429
+ - Whether the amount feels reasonable given their overall picture
1430
+ - One specific observation (not a lecture)
1431
+
1432
+ Keep it under 150 words. Calm, non-judgmental tone. No lists — just conversational prose."""
1433
+ else:
1434
+ prompt = """Give the user a brief overview of their cash flow this month.
1435
+
1436
+ Cover:
1437
+ - Overall picture (income vs. expenses)
1438
+ - The category that stands out most
1439
+ - One thing worth noticing (not a lecture, not advice unless it's genuinely useful)
1440
+
1441
+ Keep it under 200 words. Warm, doctor-patient tone. Prose only, no lists."""
1442
+
1443
+ def generate():
1444
+ for token in stream_answer(prompt, csv_user_id, financial_context=financial_context):
1445
+ yield f"data: {json.dumps({'chunk': token})}\n\n"
1446
+ yield "data: [DONE]\n\n"
1447
+
1448
+ return StreamingResponse(generate(), media_type="text/event-stream")
1449
+
1450
+
1451
+ @app.post("/csv/yearsphere/data")
1452
+ async def csv_yearsphere_data(request: Request):
1453
+ """Year sphere data from CSV transactions."""
1454
+ body = await request.json()
1455
+ transactions = body.get("transactions", [])
1456
+
1457
+ from collections import defaultdict
1458
+ from datetime import date
1459
+
1460
+ # Group by month
1461
+ monthly = defaultdict(lambda: {'income': 0, 'expenses': 0, 'transactions': []})
1462
+
1463
+ for txn in transactions:
1464
+ txn_date_str = txn.get('date', '')
1465
+ try:
1466
+ txn_date = date.fromisoformat(txn_date_str)
1467
+ except (ValueError, TypeError):
1468
+ continue
1469
+
1470
+ month_key = f"{txn_date.year}-{txn_date.month:02d}"
1471
+ amount = txn.get('amount', 0)
1472
+ category = txn.get('personal_finance_category', {}).get('primary', '')
1473
+
1474
+ # Skip transfers
1475
+ if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
1476
+ continue
1477
+
1478
+ if amount < 0:
1479
+ monthly[month_key]['income'] += abs(amount)
1480
+ else:
1481
+ monthly[month_key]['expenses'] += amount
1482
+
1483
+ monthly[month_key]['transactions'].append({
1484
+ 'date': txn_date_str,
1485
+ 'name': txn.get('name', ''),
1486
+ 'amount': amount,
1487
+ 'category': category,
1488
+ })
1489
+
1490
+ # Build months array
1491
+ months = []
1492
+ for month_key in sorted(monthly.keys()):
1493
+ data = monthly[month_key]
1494
+ months.append({
1495
+ 'month': month_key,
1496
+ 'income': round(data['income'], 2),
1497
+ 'expenses': round(data['expenses'], 2),
1498
+ 'net': round(data['income'] - data['expenses'], 2),
1499
+ 'transaction_count': len(data['transactions']),
1500
+ 'top_transactions': sorted(data['transactions'], key=lambda x: abs(x['amount']), reverse=True)[:5],
1501
+ })
1502
+
1503
+ return {'months': months}
plaid_client.py CHANGED
@@ -618,4 +618,104 @@ def get_recurring_from_fixtures() -> dict:
618
  'recurring_income': [],
619
  'projected_events': [],
620
  'analysis_period': None,
621
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
  'recurring_income': [],
619
  'projected_events': [],
620
  'analysis_period': None,
621
+ }
622
+
623
+
624
+
625
+ def _build_synthetic_balances_from_transactions(transactions: list) -> dict:
626
+ """
627
+ CSV mode: infer account balances from transaction history.
628
+ We don't have real balances (CSV doesn't include them), so we estimate:
629
+ - For each account_id in the transactions, sum credits - debits to get an inferred balance
630
+ - Use latest transaction as reference point
631
+
632
+ Returns a Plaid-shaped response so downstream formatters work unchanged.
633
+ """
634
+ from collections import defaultdict
635
+
636
+ account_totals = defaultdict(float)
637
+ account_names = {}
638
+ latest_dates = {}
639
+
640
+ for txn in transactions:
641
+ acc_id = txn.get('account_id', 'csv_account_default')
642
+ amount = txn.get('amount', 0)
643
+ # Plaid convention: positive = expense (money out), negative = income (money in)
644
+ # So current balance = sum of (-amount) = money in minus money out
645
+ account_totals[acc_id] += (-amount)
646
+
647
+ # Track a display name — use account_id or default
648
+ if acc_id not in account_names:
649
+ account_names[acc_id] = f"Uploaded Account {acc_id[-4:]}" if len(acc_id) > 4 else "Uploaded Account"
650
+
651
+ # Track latest date
652
+ txn_date = txn.get('date', '')
653
+ if acc_id not in latest_dates or txn_date > latest_dates[acc_id]:
654
+ latest_dates[acc_id] = txn_date
655
+
656
+ # Build a Plaid-shaped accounts response
657
+ accounts = []
658
+ for acc_id, running_total in account_totals.items():
659
+ accounts.append({
660
+ 'account_id': acc_id,
661
+ 'name': account_names[acc_id],
662
+ 'subtype': 'checking', # assume chequing for CSV uploads
663
+ 'balances': {
664
+ 'current': round(running_total, 2),
665
+ 'available': round(running_total, 2),
666
+ 'limit': None,
667
+ }
668
+ })
669
+
670
+ if not accounts:
671
+ # Fallback: single generic account
672
+ accounts.append({
673
+ 'account_id': 'csv_account_default',
674
+ 'name': 'Uploaded Account',
675
+ 'subtype': 'checking',
676
+ 'balances': {'current': 0, 'available': 0, 'limit': None}
677
+ })
678
+
679
+ return {'accounts': accounts}
680
+
681
+
682
+ def get_snapshot_from_csv(transactions: list) -> str:
683
+ """CSV mode: build financial snapshot from user-uploaded transactions."""
684
+ try:
685
+ balances = _build_synthetic_balances_from_transactions(transactions)
686
+ balance_text = _format_balances(balances)
687
+ transaction_text = _format_transactions(transactions)
688
+ return f"{balance_text}\n\n{transaction_text}"
689
+ except Exception as e:
690
+ print(f"CSV snapshot error: {e}", flush=True)
691
+ return "NO_BANK_DATA"
692
+
693
+
694
+ def get_chart_data_from_csv(transactions: list) -> dict:
695
+ """CSV mode: build chart data from user-uploaded transactions."""
696
+ end_date = date.today()
697
+ start_date = end_date.replace(day=1)
698
+ try:
699
+ balances = _build_synthetic_balances_from_transactions(transactions)
700
+ return _build_chart_data(balances, transactions, start_date, end_date)
701
+ except Exception as e:
702
+ print(f"CSV chart_data error: {e}", flush=True)
703
+ return {"burndown": [], "income_total": 0, "expense_total": 0, "accounts": []}
704
+
705
+
706
+ def get_recurring_from_csv(transactions: list) -> dict:
707
+ """CSV mode: detect recurring patterns from user-uploaded transactions."""
708
+ end_date = date.today()
709
+ start_date = end_date - timedelta(days=180)
710
+ try:
711
+ return _detect_recurring(transactions, start_date, end_date)
712
+ except Exception as e:
713
+ print(f"CSV recurring error: {e}", flush=True)
714
+ return {
715
+ 'recurring_expenses': [],
716
+ 'recurring_income': [],
717
+ 'projected_events': [],
718
+ 'analysis_period': None,
719
+ }
720
+
721
+