MJ-Prod commited on
Commit
fb03164
Β·
1 Parent(s): 532bf12

csv Data_3

Browse files
Files changed (3) hide show
  1. app.py +109 -370
  2. fiscal.py +12 -47
  3. plaid_client.py +6 -2
app.py CHANGED
@@ -75,310 +75,6 @@ def health():
75
  return {"status": "ok", "service": "FISCAL"}
76
 
77
 
78
- def _build_cashflow_data(all_transactions: list) -> dict:
79
- """
80
- Shared cash flow data builder.
81
- Returns income + expense breakdown grouped by category and merchant.
82
- Used by both /trial/cashflow/data and /csv/cashflow/data.
83
- """
84
- from datetime import date, timedelta
85
- from collections import defaultdict
86
- import re
87
-
88
- end_date = date.today()
89
- start_date = end_date - timedelta(days=30)
90
-
91
- income_total = 0.0
92
- category_data = defaultdict(lambda: {
93
- "total": 0.0,
94
- "merchants": defaultdict(lambda: {"total": 0.0, "count": 0, "transactions": []}),
95
- "count": 0,
96
- })
97
-
98
- for txn in all_transactions:
99
- txn_date_val = txn.get('date')
100
- if isinstance(txn_date_val, str):
101
- try:
102
- txn_date = date.fromisoformat(txn_date_val)
103
- except ValueError:
104
- continue
105
- else:
106
- txn_date = txn_date_val
107
-
108
- if txn_date < start_date or txn_date > end_date:
109
- continue
110
-
111
- amount = txn['amount']
112
- name = txn.get('name', 'Unknown')
113
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
114
-
115
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
116
- continue
117
-
118
- name_lower = name.lower()
119
- is_income = amount < 0 or (
120
- category == 'LOAN_PAYMENTS' and
121
- any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages'])
122
- )
123
-
124
- if is_income:
125
- income_total += abs(amount)
126
- continue
127
-
128
- if category == 'LOAN_PAYMENTS':
129
- continue
130
-
131
- normalized_merchant = re.sub(r'[0-9#]+', '', name).strip()
132
- normalized_merchant = re.sub(r'\s+', ' ', normalized_merchant).upper() or name
133
-
134
- category_data[category]["total"] += amount
135
- category_data[category]["count"] += 1
136
- category_data[category]["merchants"][normalized_merchant]["total"] += amount
137
- category_data[category]["merchants"][normalized_merchant]["count"] += 1
138
- category_data[category]["merchants"][normalized_merchant]["transactions"].append({
139
- "date": str(txn_date),
140
- "amount": round(amount, 2),
141
- "name": name,
142
- })
143
-
144
- categories = []
145
- for cat_name, cat_info in category_data.items():
146
- merchants = []
147
- for merchant_name, merchant_info in cat_info["merchants"].items():
148
- merchants.append({
149
- "name": merchant_name,
150
- "display_name": merchant_info["transactions"][0]["name"] if merchant_info["transactions"] else merchant_name,
151
- "total": round(merchant_info["total"], 2),
152
- "count": merchant_info["count"],
153
- "transactions": sorted(
154
- merchant_info["transactions"],
155
- key=lambda x: x["date"],
156
- reverse=True
157
- )[:10],
158
- })
159
- merchants.sort(key=lambda x: -x["total"])
160
-
161
- readable_name = cat_name.replace("_", " ").title()
162
-
163
- categories.append({
164
- "name": readable_name,
165
- "raw_name": cat_name,
166
- "total": round(cat_info["total"], 2),
167
- "count": cat_info["count"],
168
- "merchants": merchants,
169
- })
170
-
171
- categories.sort(key=lambda x: -x["total"])
172
-
173
- return {
174
- "income_total": round(income_total, 2),
175
- "categories": categories,
176
- "date_range": {
177
- "start": str(start_date),
178
- "end": str(end_date),
179
- }
180
- }
181
-
182
- def _build_yearsphere_data(all_transactions: list, recurring_data: dict) -> dict:
183
- """
184
- Shared Year Sphere data builder.
185
- Takes raw transactions + recurring analysis, returns full sphere data shape.
186
- Used by both /trial/yearsphere/data and /csv/yearsphere/data.
187
- """
188
- from datetime import date
189
- from collections import defaultdict
190
- from calendar import monthrange
191
-
192
- recurring_expenses = recurring_data.get("recurring_expenses", [])
193
- recurring_income = recurring_data.get("recurring_income", [])
194
- projected_events = recurring_data.get("projected_events", [])
195
-
196
- today = date.today()
197
-
198
- # Build the month window: 8 months back through 3 months forward
199
- window = []
200
- for offset in range(-8, 4):
201
- target_month = today.month + offset
202
- target_year = today.year
203
- while target_month < 1:
204
- target_month += 12
205
- target_year -= 1
206
- while target_month > 12:
207
- target_month -= 12
208
- target_year += 1
209
- window.append((target_year, target_month))
210
-
211
- # Build per-month recurring index
212
- recurring_by_month = defaultdict(list)
213
- seen_month_names = set()
214
-
215
- def add_recurring_occurrence(year, month, day, name, amount, is_income):
216
- dedupe_key = (year, month, name.upper().strip(), day)
217
- if dedupe_key in seen_month_names:
218
- return
219
- seen_month_names.add(dedupe_key)
220
- recurring_by_month[(year, month)].append({
221
- "name": name[:30],
222
- "amount": round(abs(amount), 2),
223
- "date": f"{year:04d}-{month:02d}-{day:02d}",
224
- "is_income": is_income,
225
- })
226
-
227
- for item in recurring_expenses:
228
- for hist in item.get("history", []):
229
- hist_date = date.fromisoformat(hist["date"])
230
- add_recurring_occurrence(
231
- hist_date.year, hist_date.month, hist_date.day,
232
- item["merchant"], hist["amount"], is_income=False,
233
- )
234
- for item in recurring_income:
235
- for hist in item.get("history", []):
236
- hist_date = date.fromisoformat(hist["date"])
237
- add_recurring_occurrence(
238
- hist_date.year, hist_date.month, hist_date.day,
239
- item["merchant"], hist["amount"], is_income=True,
240
- )
241
-
242
- for ev in projected_events:
243
- ev_date = date.fromisoformat(ev["date"])
244
- if ev_date <= today:
245
- continue
246
- add_recurring_occurrence(
247
- ev_date.year, ev_date.month, ev_date.day,
248
- ev.get("merchant", "Unknown"), ev.get("amount", 0.0),
249
- is_income=ev.get("is_income", False),
250
- )
251
-
252
- # Build per-month output
253
- months_out = []
254
- for (year, month) in window:
255
- month_start = date(year, month, 1)
256
- _, last_day = monthrange(year, month)
257
- month_end = date(year, month, last_day)
258
-
259
- is_past = month_end < today
260
- is_current = month_start <= today <= month_end
261
- is_future = month_start > today
262
-
263
- total_spent = 0.0
264
- total_income = 0.0
265
- daily_spending = defaultdict(float)
266
- daily_transactions = defaultdict(list)
267
-
268
- if not is_future:
269
- for txn in all_transactions:
270
- txn_date_val = txn.get('date')
271
- if isinstance(txn_date_val, str):
272
- try:
273
- txn_date = date.fromisoformat(txn_date_val)
274
- except ValueError:
275
- continue
276
- else:
277
- txn_date = txn_date_val
278
-
279
- if txn_date < month_start or txn_date > month_end:
280
- continue
281
-
282
- amount = txn['amount']
283
- name = txn.get('name', 'Unknown')
284
- category = txn.get('personal_finance_category', {}).get('primary', 'OTHER')
285
- name_lower = name.lower()
286
-
287
- if category in ['TRANSFER_IN', 'TRANSFER_OUT']:
288
- continue
289
-
290
- is_income = amount < 0 or (
291
- category == 'LOAN_PAYMENTS' and
292
- any(w in name_lower for w in ['payroll', 'salary', 'direct dep', 'employer', 'wages'])
293
- )
294
-
295
- daily_transactions[str(txn_date)].append({
296
- "name": name[:30],
297
- "amount": round(abs(amount), 2),
298
- "is_income": is_income,
299
- "category": category,
300
- })
301
-
302
- if is_income:
303
- total_income += abs(amount)
304
- else:
305
- total_spent += amount
306
- daily_spending[str(txn_date)] += amount
307
-
308
- for rec in recurring_by_month.get((year, month), []):
309
- rec_date_str = rec["date"]
310
- already_in_day = any(
311
- t["name"].upper().strip() == rec["name"].upper().strip()
312
- for t in daily_transactions.get(rec_date_str, [])
313
- )
314
- if not already_in_day:
315
- daily_transactions[rec_date_str].append({
316
- "name": rec["name"],
317
- "amount": rec["amount"],
318
- "is_income": rec["is_income"],
319
- "category": "RECURRING",
320
- })
321
- if is_future:
322
- if rec["is_income"]:
323
- total_income += rec["amount"]
324
- else:
325
- total_spent += rec["amount"]
326
- daily_spending[rec_date_str] += rec["amount"]
327
-
328
- month_recurring = recurring_by_month.get((year, month), [])
329
- recurring_income_items = [
330
- {"name": r["name"], "amount": r["amount"], "date": r["date"]}
331
- for r in month_recurring if r["is_income"]
332
- ]
333
- recurring_expense_items = [
334
- {"name": r["name"], "amount": r["amount"], "date": r["date"]}
335
- for r in month_recurring if not r["is_income"]
336
- ]
337
-
338
- if is_future:
339
- total_spent = 0.0
340
-
341
- days_in_month = last_day
342
- max_day_spending = max(daily_spending.values()) if daily_spending else 1
343
- daily_data = []
344
- for day_num in range(1, days_in_month + 1):
345
- day_str = str(date(year, month, day_num))
346
- spent = daily_spending.get(day_str, 0.0)
347
- intensity = spent / max_day_spending if max_day_spending > 0 else 0
348
- daily_data.append({
349
- "day": day_num,
350
- "amount": round(spent, 2),
351
- "intensity": round(intensity, 2),
352
- "transactions": daily_transactions.get(day_str, []),
353
- })
354
-
355
- first_weekday = month_start.weekday()
356
- first_weekday = (first_weekday + 1) % 7
357
-
358
- months_out.append({
359
- "year": year,
360
- "month": month,
361
- "month_name": month_start.strftime("%b").upper(),
362
- "month_name_full": month_start.strftime("%B"),
363
- "total_spent": round(total_spent, 2),
364
- "total_income": round(total_income, 2),
365
- "is_past": is_past,
366
- "is_current": is_current,
367
- "is_future": is_future,
368
- "daily_data": daily_data,
369
- "first_weekday": first_weekday,
370
- "days_in_month": days_in_month,
371
- "recurring_income": sorted(recurring_income_items, key=lambda x: -x["amount"])[:5],
372
- "recurring_expenses": sorted(recurring_expense_items, key=lambda x: -x["amount"])[:10],
373
- })
374
-
375
- current_idx = next((i for i, m in enumerate(months_out) if m["is_current"]), 8)
376
-
377
- return {
378
- "months": months_out,
379
- "current_month_index": current_idx,
380
- }
381
-
382
 
383
  def get_cached_financial_context(access_token: str, user_id: str) -> str:
384
  now = time.time()
@@ -1489,7 +1185,9 @@ async def trial_yearsphere_data(request: Request):
1489
  "current_month_index": current_idx,
1490
  }
1491
 
1492
-
 
 
1493
  @app.post("/csv/chart_data")
1494
  async def csv_chart_data(request: Request):
1495
  """Chart data from user-uploaded CSV transactions."""
@@ -1646,12 +1344,70 @@ async def csv_prescription_activity(request: Request):
1646
 
1647
  @app.post("/csv/cashflow/data")
1648
  async def csv_cashflow_data(request: Request):
1649
- """Cash flow data from user-uploaded CSV transactions."""
1650
  body = await request.json()
1651
  transactions = body.get("transactions", [])
1652
- return _build_cashflow_data(transactions)
1653
-
1654
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1655
 
1656
  @app.post("/csv/cashflow/opinion")
1657
  async def csv_cashflow_opinion(request: Request):
@@ -1692,73 +1448,56 @@ Keep it under 200 words. Warm, doctor-patient tone. Prose only, no lists."""
1692
  return StreamingResponse(generate(), media_type="text/event-stream")
1693
 
1694
 
1695
-
1696
-
1697
- # =============================================================================
1698
- # NOW REPLACE the /csv/yearsphere/data endpoint with this:
1699
- # =============================================================================
1700
-
1701
  @app.post("/csv/yearsphere/data")
1702
  async def csv_yearsphere_data(request: Request):
1703
- """Year Sphere data from user-uploaded CSV transactions."""
1704
- from plaid_client import get_recurring_from_csv
1705
-
1706
  body = await request.json()
1707
  transactions = body.get("transactions", [])
1708
 
1709
- if not transactions:
1710
- return {"months": [], "current_month_index": 0}
1711
-
1712
- try:
1713
- recurring_data = get_recurring_from_csv(transactions)
1714
- except Exception as e:
1715
- print(f"CSV yearsphere recurring error: {e}", flush=True)
1716
- recurring_data = {"recurring_expenses": [], "recurring_income": [], "projected_events": []}
1717
-
1718
- return _build_yearsphere_data(transactions, recurring_data)
1719
-
1720
-
1721
-
1722
-
1723
- @app.post("/csv/categorize")
1724
- async def csv_categorize(request: Request):
1725
- """Categorize PENDING transactions using the AI."""
1726
- from fiscal import categorize_transactions_batched
1727
-
1728
- body = await request.json()
1729
- transactions = body.get("transactions", [])
1730
-
1731
- if not transactions:
1732
- return {"transactions": []}
1733
-
1734
- pending_indices = []
1735
- pending_descriptions = []
1736
-
1737
- for i, txn in enumerate(transactions):
1738
- current_cat = txn.get('personal_finance_category', {}).get('primary', '')
1739
- if current_cat == 'PENDING_CATEGORIZATION':
1740
- pending_indices.append(i)
1741
- desc = txn.get('raw_description') or txn.get('name', 'Unknown')
1742
- pending_descriptions.append(desc)
1743
-
1744
- if not pending_descriptions:
1745
- return {
1746
- "transactions": transactions,
1747
- "categorized_count": 0,
1748
- "total_count": len(transactions),
1749
- }
1750
 
1751
- print(f"Categorizing {len(pending_descriptions)} pending transactions...", flush=True)
 
1752
 
1753
- ai_categories = categorize_transactions_batched(pending_descriptions)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1754
 
1755
- for idx, category in zip(pending_indices, ai_categories):
1756
- if 'personal_finance_category' not in transactions[idx]:
1757
- transactions[idx]['personal_finance_category'] = {}
1758
- transactions[idx]['personal_finance_category']['primary'] = category
 
 
 
 
 
 
 
 
1759
 
1760
- return {
1761
- "transactions": transactions,
1762
- "categorized_count": len(pending_descriptions),
1763
- "total_count": len(transactions),
1764
- }
 
75
  return {"status": "ok", "service": "FISCAL"}
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  def get_cached_financial_context(access_token: str, user_id: str) -> str:
80
  now = time.time()
 
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."""
 
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):
 
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}
 
 
 
 
fiscal.py CHANGED
@@ -6,9 +6,6 @@ from langchain_community.vectorstores import Chroma
6
  from langchain_community.retrievers import BM25Retriever
7
  from langchain_core.prompts import PromptTemplate
8
  from langchain_core.documents import Document
9
- from langchain_ollama import ChatOllama
10
- import json as _json
11
- import re as _re
12
 
13
  try:
14
  from langchain.retrievers import EnsembleRetriever
@@ -51,20 +48,20 @@ retriever = EnsembleRetriever(
51
  )
52
 
53
  # ---------- LLM ----------
54
- # endpoint = HuggingFaceEndpoint(
55
- # repo_id="Qwen/Qwen2.5-14B-Instruct",
56
- # huggingfacehub_api_token=os.environ["HF_API_KEY"],
57
- # provider="featherless-ai",
58
- # temperature=0.3,
59
- # max_new_tokens=1024,
60
- # )
61
- # llm = ChatHuggingFace(llm=endpoint)
62
- # LOCAL
63
- # ---------- LLM ----------
64
- llm = ChatOllama(
65
- model="qwen2.5:14b-instruct-q5_K_M",
66
  temperature=0.3,
 
67
  )
 
 
 
 
 
 
 
68
 
69
 
70
  # ---------- Prompts ----------
@@ -95,37 +92,6 @@ Think of how a good doctor talks to a patient β€” not a chirpy app, not a cold s
95
  - Use collaborative language ("if we looked at cutting X" / "one option worth considering") instead of commanding language ("you need to cut X").
96
  - Normalize before advising when the user sounds stressed or self-critical β€” a line like "a lot of people are in the same spot" costs nothing and reduces shame.
97
 
98
- HOW YOU HOLD YOUR GROUND:
99
- - Push back gently on risky moves. If the user proposes something financially self-destructive (using rent money for a want, taking on high-interest debt for something optional, cancelling a savings buffer to fund lifestyle), do not just validate. Surface the concern honestly: "Before I help with that β€” want me to name what I'm seeing? Not to talk you out of it, just so you're deciding with full picture." Users trust an AI that's willing to disagree with them.
100
- - Ask permission before delivering hard information. When you're about to share something the user may find difficult (a shortfall, a pattern they've avoided, a real risk), pause first: "I've noticed something worth talking about β€” want to hear it now, or would you rather come back to it?" Respect the answer. Never ambush.
101
- - Say nothing when there is nothing important to say. If the user checks in and everything is genuinely steady, say so plainly: "Nothing unusual this week β€” you're in a calm spot. No action needed." Do not manufacture insights or invent flags just to sound useful. A user closing the app feeling reassured is a win. Silence is a valid response.
102
- - Prioritize the user's long-term well-being over their momentary comfort or engagement. If the user is spiraling (asking the same question repeatedly, checking multiple times a day, using anxious language across turns), gently name the pattern rather than continuing to feed the loop: "We've talked about this a few times today β€” the numbers haven't changed. Sometimes when we keep checking, it's less about the number and more about how it feels. Anything you want to talk about that's making this feel unsettled?"
103
- - If the user's message shows signs of shame or self-criticism ("I know I shouldn't have," "I feel dumb for asking," "I'm probably in trouble"), address the shame layer first before the facts. The facts don't help if the user is drowning in judgment about themselves.
104
-
105
- READING THE USER (VOCAL INTUITION):
106
- Read the user's message for emotional signals before responding. Different signals call for different responses.
107
-
108
- Signals of anxiety or shame:
109
- - Long, run-on messages with many "and" or "what if" β€” user is spilling, overwhelmed. Slow the pace. Do not answer everything at once. Offer to walk through piece by piece.
110
- - Short, clipped messages ("just tell me," "how bad is it," "the number please") β€” user is braced for impact. Answer directly and honestly, no cushioning fluff, then offer more context only if they want it.
111
- - Shame words ("I know I shouldn't have," "I feel dumb," "I'm probably in trouble," "please don't judge") β€” address the shame layer first, before any facts. The facts don't help if the user is drowning in judgment about themselves.
112
- - Hedging language ("so like... I know this might not be the right time but... I was kind of thinking about maybe...") β€” the hedging IS the signal. User is expecting judgment. Dismantle that expectation before answering.
113
- - Preemptive apology ("sorry to bother you," "this is dumb but") β€” respond with warmth first, address the actual question second. Never confirm the apology by treating the question as small.
114
-
115
- Signals of shutdown:
116
- - Very short, no punctuation, no context β€” user has already decided the news is bad. Match their energy in a calm, grounded way. Give the honest answer plainly, without buffering.
117
- - Repeated returns to the same topic β€” user is not looking for new information, they are looking for reassurance that isn't landing. Do not repeat the same answer louder. Gently name what you're noticing: "we've talked about this a few times β€” the numbers haven't changed. Sometimes when we keep checking, it's less about the number and more about the feeling. Anything you want to talk about?"
118
-
119
- Signals of overwhelm:
120
- - Multiple questions crammed into one message β€” user's mind is racing. Do not answer all of them. Pick the one you think matters most, answer that one calmly, and offer to come back to the others.
121
- - Long question about many categories at once β€” user does not need a comprehensive report. Offer to start with one thing: "there's a lot in here. Want to start with the biggest piece, or is there a specific one weighing on you?"
122
-
123
- Signals of calm engagement:
124
- - Direct question, normal punctuation, no anxious language β€” user is in problem-solving mode. Answer directly and offer useful next steps. No need for extra warmth padding.
125
-
126
- Match the emotional register you read. Do not respond to a shutdown user with paragraphs of empathy β€” they want the number. Do not respond to a spilling user with just a number β€” they want to feel heard first.
127
-
128
-
129
  STRICT COMPLIANCE GUARDRAILS:
130
  - You provide automated budgeting analysis and lifestyle financial coaching for educational and informational purposes only.
131
  - You DO NOT provide certified financial, legal, tax, or investment advice. You do not operate under a fiduciary duty.
@@ -310,7 +276,6 @@ Standalone Question:"""
310
  memory.chat_memory.add_ai_message(clean_response)
311
 
312
 
313
-
314
  # =============================================================================
315
  # CSV TRANSACTION CATEGORIZATION (Phase 3)
316
  # Uses the same Ollama LLM to categorize transactions from uploaded CSVs
 
6
  from langchain_community.retrievers import BM25Retriever
7
  from langchain_core.prompts import PromptTemplate
8
  from langchain_core.documents import Document
 
 
 
9
 
10
  try:
11
  from langchain.retrievers import EnsembleRetriever
 
48
  )
49
 
50
  # ---------- LLM ----------
51
+ endpoint = HuggingFaceEndpoint(
52
+ repo_id="meta-llama/Llama-3.3-70B-Instruct",
53
+ huggingfacehub_api_token=os.environ["HF_API_KEY"],
54
+ provider="together",
 
 
 
 
 
 
 
 
55
  temperature=0.3,
56
+ max_new_tokens=1024,
57
  )
58
+ llm = ChatHuggingFace(llm=endpoint)
59
+ # LOCAL
60
+ # ---------- LLM ----------
61
+ # llm = ChatOllama(
62
+ # model="qwen2.5:14b-instruct-q5_K_M",
63
+ # temperature=0.3,
64
+ # )
65
 
66
 
67
  # ---------- Prompts ----------
 
92
  - Use collaborative language ("if we looked at cutting X" / "one option worth considering") instead of commanding language ("you need to cut X").
93
  - Normalize before advising when the user sounds stressed or self-critical β€” a line like "a lot of people are in the same spot" costs nothing and reduces shame.
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  STRICT COMPLIANCE GUARDRAILS:
96
  - You provide automated budgeting analysis and lifestyle financial coaching for educational and informational purposes only.
97
  - You DO NOT provide certified financial, legal, tax, or investment advice. You do not operate under a fiduciary duty.
 
276
  memory.chat_memory.add_ai_message(clean_response)
277
 
278
 
 
279
  # =============================================================================
280
  # CSV TRANSACTION CATEGORIZATION (Phase 3)
281
  # Uses the same Ollama LLM to categorize transactions from uploaded CSVs
plaid_client.py CHANGED
@@ -62,7 +62,6 @@ def _load_raw_balances():
62
  raise FileNotFoundError(f"Fixture not found: {path}")
63
  return json.loads(path.read_text())
64
 
65
- from datetime import date, timedelta
66
 
67
  def _shift_fixture_to_today(fixture):
68
  """
@@ -100,6 +99,8 @@ def _load_raw_transactions():
100
  fixture = json.loads(path.read_text())
101
  return _shift_fixture_to_today(fixture)
102
 
 
 
103
  # ---------- REAL: Balances ----------
104
 
105
  def get_balances(access_token: str) -> str:
@@ -620,6 +621,7 @@ def get_recurring_from_fixtures() -> dict:
620
  }
621
 
622
 
 
623
  def _build_synthetic_balances_from_transactions(transactions: list) -> dict:
624
  """
625
  CSV mode: infer account balances from transaction history.
@@ -714,4 +716,6 @@ def get_recurring_from_csv(transactions: list) -> dict:
714
  'recurring_income': [],
715
  'projected_events': [],
716
  'analysis_period': None,
717
- }
 
 
 
62
  raise FileNotFoundError(f"Fixture not found: {path}")
63
  return json.loads(path.read_text())
64
 
 
65
 
66
  def _shift_fixture_to_today(fixture):
67
  """
 
99
  fixture = json.loads(path.read_text())
100
  return _shift_fixture_to_today(fixture)
101
 
102
+
103
+
104
  # ---------- REAL: Balances ----------
105
 
106
  def get_balances(access_token: str) -> str:
 
621
  }
622
 
623
 
624
+
625
  def _build_synthetic_balances_from_transactions(transactions: list) -> dict:
626
  """
627
  CSV mode: infer account balances from transaction history.
 
716
  'recurring_income': [],
717
  'projected_events': [],
718
  'analysis_period': None,
719
+ }
720
+
721
+