csv Data_3
Browse files- app.py +109 -370
- fiscal.py +12 -47
- 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
|
| 1650 |
body = await request.json()
|
| 1651 |
transactions = body.get("transactions", [])
|
| 1652 |
-
|
| 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
|
| 1704 |
-
from plaid_client import get_recurring_from_csv
|
| 1705 |
-
|
| 1706 |
body = await request.json()
|
| 1707 |
transactions = body.get("transactions", [])
|
| 1708 |
|
| 1709 |
-
|
| 1710 |
-
|
| 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 |
-
|
|
|
|
| 1752 |
|
| 1753 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1754 |
|
| 1755 |
-
|
| 1756 |
-
|
| 1757 |
-
|
| 1758 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 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 |
+
|