File size: 21,382 Bytes
b73fdd8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
import os
import io
import re
import json
import uuid
from datetime import datetime
from typing import List, Dict, Any, Tuple

import pandas as pd
import numpy as np
import streamlit as st
import pdfplumber
from rapidfuzz import process, fuzz
from dotenv import load_dotenv

# # Load your API key from .env
# load_dotenv()
# GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")

# genai.configure(api_key=GEMINI_API_KEY)

# model = genai.GenerativeModel("gemini-1.5-pro")  # or "gemini-1.5-flash" for faster/cheaper

# prompt = """
# Ask Gemini for:
#   - Monthly budget planning
#   - Suggestions to reduce unnecessary spending
#   - Personalized financial advice
# Output expected as Markdown with clearly labeled sections.
# """

# response = model.generate_content(prompt)
# print(response.text)

# Optional OCR imports (handled gracefully if missing)
try:
    import pytesseract  # type: ignore
    from pdf2image import convert_from_bytes  # type: ignore
    OCR_AVAILABLE = True
except Exception:
    OCR_AVAILABLE = False

# --------- LLM (Gemini) setup ---------
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
GENAI_READY = False
GEMINI_MODEL_NAME = os.getenv("GEMINI_MODEL", "gemini-1.5-flash")
try:
    if GEMINI_API_KEY:
        import google.generativeai as genai
        genai.configure(api_key=GEMINI_API_KEY)
        GENAI_READY = True
except Exception:
    GENAI_READY = False

# --------- Streamlit page config and styles ---------
st.set_page_config(page_title="AI Personal Finance Assistant", page_icon="💰", layout="wide")
st.markdown(
    """

    <style>

    .main-title { text-align:center; font-size:34px; font-weight:700; color:#4CAF50; text-shadow: 2px 2px 5px rgba(76,175,80,.4);} 

    .sub-title { text-align:center; font-size:18px; color:#ddd; margin-bottom:20px;}

    .result-card { background: rgba(0, 150, 136, 0.08); padding: 16px; border-radius: 10px; margin: 8px 0; box-shadow: 0 2px 8px rgba(0,0,0,.06);} 

    .success-banner { background: linear-gradient(to right, #2E7D32, #1B5E20); color:#fff; padding:14px; border-radius:10px; text-align:center; font-weight:700; margin-top:10px;}

    </style>

    """,
    unsafe_allow_html=True,
)
st.markdown('<h1 class="main-title">💰 AI-Powered Personal Finance Assistant</h1>', unsafe_allow_html=True)
st.markdown('<p class="sub-title">Upload your UPI/Bank statements (Paytm, GPay, PhonePe, etc.) → Parse → Analyze → Get insights</p>', unsafe_allow_html=True)

# --------- Sidebar ---------
st.sidebar.title("ℹ️ How to Use")
st.sidebar.write("1) Upload one or more statement PDFs.")
st.sidebar.write("2) Review parsed transactions.")
st.sidebar.write("3) (Optional) Enter monthly income for savings%.")
st.sidebar.write("4) Generate LLM insights or use offline analytics.")

with st.sidebar.expander("⚙️ Settings", expanded=False):
    user_monthly_income = st.number_input("Monthly Income (₹) — optional", min_value=0, step=1000, value=0)
    enable_ocr = st.checkbox("Enable OCR fallback (slower)", value=False)
    chunk_limit = st.number_input("Max rows sent to LLM", min_value=100, max_value=5000, value=800, step=100)
    show_offline_insights = st.checkbox("Show offline insights (no LLM)", value=True)

if GEMINI_API_KEY:
    st.sidebar.success("Gemini API key detected from environment.")
else:
    st.sidebar.warning("No GEMINI_API_KEY found. LLM features will be disabled. Offline insights still work.")

# --------- Helpers (parsing, normalization, categorization) ---------
DATE_PATTERNS = [
    "%d-%m-%Y", "%d/%m/%Y", "%Y-%m-%d", "%d-%b-%Y", "%d %b %Y", "%d %B %Y"
]
DATE_RE = re.compile(r"\b(\d{2}[/-]\d{2}[/-]\d{4}|\d{4}-\d{2}-\d{2}|\d{2}\s?[A-Za-z]{3}\s?\d{4})\b")

AMOUNT_RE = re.compile(
    r"(?:₹|INR)?\s*([+-]?\d{1,3}(?:,\d{3})*(?:\.\d{1,2})|[+-]?\d+(?:\.\d{1,2}))"
)

DEBIT_KEYS = {
    "debit", "debited", "withdrawal", "withdraw", "purchase", "payment", "paid", "upi to",
    "money sent", "sent", "transfer to", "atm", "pos", "bill", "emi", "petrol", "fuel", "dining",
    "zomato", "swiggy", "insurance premium", "online purchase"
}
CREDIT_KEYS = {
    "credit", "credited", "cr", "received", "refund", "reversal", "cashback", "salary",
    "deposit", "transfer from", "interest", "redeem", "redemption"
}
BALANCE_KEYS = {"opening balance", "closing balance", "balance only", "closingbal", "openingbal"}

CATEGORY_RULES = {
    "food": ["swiggy", "zomato", "restaurant", "hotel", "food"],
    "grocery": ["bigbasket", "dmart", "grocery", "mart", "supermarket"],
    "utilities": ["electricity", "water", "gas", "biller", "broadband", "internet", "dth"],
    "transport": ["uber", "ola", "metro", "fuel", "petrol", "diesel", "fastag"],
    "shopping": ["amazon", "flipkart", "myntra", "ajio", "store", "shop"],
    "entertainment": ["netflix", "prime", "hotstar", "movie", "bookmyshow"],
    "financial": ["bank", "emi", "loan", "insurance", "sip", "mutual fund"],
    "transfer": ["upi", "imps", "neft", "rtgs", "to", "from"],
}

UPI_ID_RE = re.compile(r"[A-Za-z0-9._-]+@[A-Za-z]+")
MERCHANT_CANON = {
    "swiggy": "Swiggy", "zomato": "Zomato", "amazon": "Amazon",
    "flipkart": "Flipkart", "uber": "Uber", "ola": "Ola"
}


def _normalize_date(s: str) -> str | None:
    s = s.strip()
    for fmt in DATE_PATTERNS:
        try:
            return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
        except Exception:
            continue
    return None


def _extract_texts_from_pdf(file_bytes: bytes, use_ocr: bool = False) -> str:
    text_chunks: List[str] = []
    with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
        for page in pdf.pages:
            t = page.extract_text() or ""
            # If page has no extractable text and OCR fallback is enabled
            if (not t) and use_ocr and OCR_AVAILABLE:
                try:
                    images = convert_from_bytes(file_bytes, first_page=page.page_number, last_page=page.page_number)
                    if images:
                        t = pytesseract.image_to_string(images[0]) or ""
                except Exception:
                    # If OCR fails keep text as empty string
                    t = ""
            text_chunks.append(t)
    return "\n".join(text_chunks).strip()


def _canonical_merchant(name: str) -> str:
    low = (name or "").lower()
    # direct substring match
    for key, canon in MERCHANT_CANON.items():
        if key in low:
            return canon
    # fuzzy match to known merchants
    try:
        best = process.extractOne(low, list(MERCHANT_CANON.keys()), scorer=fuzz.WRatio)
        if best and isinstance(best, tuple) and len(best) >= 2 and best[1] > 85:
            return MERCHANT_CANON.get(best[0], name)
    except Exception:
        pass
    return (name or "").strip()[:80]


def _categorize(desc: str) -> str:
    d = (desc or "").lower()
    for cat, kws in CATEGORY_RULES.items():
        if any(k in d for k in kws):
            return cat
    return "other"

# -----------------------
# Amount/type extraction helpers
# -----------------------
def _find_amounts(txt: str) -> list[float]:
    vals: list[float] = []
    for m in AMOUNT_RE.findall(txt or ""):
        s = m if isinstance(m, str) else (m[0] if isinstance(m, (list, tuple)) else str(m))
        try:
            vals.append(float(s.replace(",", "")))
        except Exception:
            pass
    return vals


def _guess_type_from_text(txt: str) -> str:
    low = (txt or "").lower()
    if any(k in low for k in BALANCE_KEYS):
        return "balance"
    if re.search(r"\bcr\b", low):
        return "credit"
    if re.search(r"\bdr\b", low):
        return "debit"
    if any(k in low for k in CREDIT_KEYS):
        return "credit"
    if any(k in low for k in DEBIT_KEYS):
        return "debit"
    if "transfer from" in low or ("from" in low and "transfer" in low):
        return "credit"
    if "transfer to" in low or ("to" in low and "transfer" in low):
        return "debit"
    return "unknown"


def _extract_amount_and_type(txt: str) -> tuple[float | None, str, float | None]:
    amounts = _find_amounts(txt)
    tx_type = _guess_type_from_text(txt)
    if tx_type == "balance" and len(amounts) == 1:
        return (None, "balance_only", amounts[0])
    balance_amt = None
    tx_amt = None
    if len(amounts) >= 2:
        # common pattern: transaction amount then balance
        balance_amt = amounts[-1]
        tx_amt = amounts[0]
    elif len(amounts) == 1:
        tx_amt = amounts[0]
    return (tx_amt, tx_type, balance_amt)

# -----------------------
# Parsing pipeline (extract_and_parse)
# -----------------------
@st.cache_data(show_spinner=False)
def extract_and_parse(files: List[Tuple[str, bytes]], use_ocr: bool = False) -> pd.DataFrame:
    all_rows: List[Dict[str, Any]] = []
    for filename, file_bytes in files:
        raw_text = _extract_texts_from_pdf(file_bytes, use_ocr=use_ocr)
        source = filename
        lines = [ln.strip() for ln in raw_text.splitlines() if ln.strip()]

        block: List[str] = []
        for ln in lines:
            if DATE_RE.search(ln) and block:
                all_rows.extend(_parse_block(" ".join(block), source))
                block = [ln]
            else:
                block.append(ln)
        if block:
            all_rows.extend(_parse_block(" ".join(block), source))

    df = pd.DataFrame(all_rows).drop_duplicates()

    if not df.empty:
        df["date"] = df["date"].apply(lambda x: _normalize_date(str(x)) or "")
        df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
        df = df.dropna(subset=["amount"]).reset_index(drop=True)
        with pd.option_context('mode.chained_assignment', None):
            df["month"] = df["date"].apply(lambda s: s[:7] if isinstance(s, str) and len(s) >= 7 else "")
        df["merchant"] = df["merchant"].fillna("").apply(_canonical_merchant)
        df["category"] = df["description"].fillna("").apply(_categorize)
        df["type"] = df["type"].fillna("")
        df.loc[~df["type"].isin(["debit", "credit"]), "type"] = "unknown"
        if "currency" not in df.columns:
            df["currency"] = "INR"
        if "transaction_id" not in df.columns:
            df["transaction_id"] = [str(uuid.uuid4()) for _ in range(len(df))]
        df["signed_amount"] = np.where(
            df["type"] == "credit", df["amount"],
            np.where(df["type"] == "debit", -df["amount"], np.nan)
        )
    return df

# -----------------------
# Improved block parser
# -----------------------

def _parse_block(txt: str, source_pdf: str) -> List[Dict[str, Any]]:
    rows: List[Dict[str, Any]] = []
    date_match = DATE_RE.search(txt or "")
    date_str = date_match.group(0) if date_match else ""
    tx_amt, tx_type, balance_amt = _extract_amount_and_type(txt or "")

    if tx_type in {"balance_only", "balance"} and tx_amt is None:
        return rows

    upi_match = UPI_ID_RE.search(txt or "")
    merchant = upi_match.group(0) if upi_match else ""
    if not merchant:
        tokens = [t for t in re.split(r"[^A-Za-z0-9@._-]+", txt or "") if t and not t.replace('.', '', 1).isdigit()]
        merchant = max(tokens, key=len)[:80] if tokens else ""

    if tx_type == "unknown":
        low = (txt or "").lower()
        if any(k in low for k in ["salary", "deposit", "refund", "received", "cashback"]):
            tx_type = "credit"
        elif any(k in low for k in ["withdraw", "payment", "purchase", "upi to", "sent", " to "]):
            tx_type = "debit"

    if tx_amt is not None:
        rows.append({
            "date": date_str,
            "time": "",
            "amount": float(tx_amt),
            "type": tx_type if tx_type in {"debit", "credit"} else "unknown",
            "merchant": merchant,
            "description": (txt or "").strip()[:300],
            "method": _infer_method_from_source(source_pdf),
            "source_pdf": source_pdf,
            "running_balance": balance_amt if balance_amt is not None else np.nan,
        })
    return rows


def _infer_method_from_source(name: str) -> str:
    low = (name or "").lower()
    if "paytm" in low:
        return "Paytm"
    if "phonepe" in low:
        return "PhonePe"
    if "gpay" in low or "google" in low:
        return "GPay"
    return "UPI"

# -----------------------
# Offline analytics (unchanged)
# -----------------------

def compute_offline_insights(df: pd.DataFrame, income: int = 0) -> Dict[str, Any]:
    out: Dict[str, Any] = {"months": {}, "top_categories": [], "top_merchants": [], "notes": []}
    if df.empty:
        return out
    month_grp = df[df["type"].isin(["debit", "credit"])].groupby(["month", "type"]).agg(total=("amount", "sum")).reset_index()
    months: Dict[str, Dict[str, float]] = {}
    for _, r in month_grp.iterrows():
        m = r["month"] or "unknown"
        if m not in months:
            months[m] = {"income": 0.0, "expenses": 0.0}
        if r["type"] == "credit":
            months[m]["income"] += float(r["total"])
        elif r["type"] == "debit":
            months[m]["expenses"] += float(r["total"])
    out["months"] = months
    cat = df[df["type"] == "debit"].groupby("category")["amount"].sum().sort_values(ascending=False)
    out["top_categories"] = [(k, float(v)) for k, v in cat.head(10).items()]
    mer = df[df["type"] == "debit"].groupby("merchant")["amount"].sum().sort_values(ascending=False)
    out["top_merchants"] = [(k, float(v)) for k, v in mer.head(10).items()]
    notes: List[str] = []
    for m, vals in months.items():
        inc = income if income > 0 else vals.get("income", 0.0)
        exp = vals.get("expenses", 0.0)
        if inc > 0:
            savings_pct = max(0.0, (inc - exp) / inc * 100.0)
            notes.append(f"{m}: Savings {savings_pct:.1f}% (Income ₹{inc:,.0f}, Expenses ₹{exp:,.0f})")
        else:
            notes.append(f"{m}: Expenses ₹{exp:,.0f} (No income provided/detected)")
    out["notes"] = notes
    return out

# -----------------------
# LLM Recommendation Generator (Gemini)
# -----------------------

def build_llm_context(df: pd.DataFrame, max_rows: int = 800) -> str:
    cols = ["date", "amount", "type", "merchant", "category", "method"]
    slim = df[cols].copy() if not df.empty else pd.DataFrame(columns=cols)
    if len(slim) > max_rows:
        slim = slim.sort_values("date").tail(max_rows)
    return slim.to_json(orient="records", date_format="iso")

@st.cache_data(show_spinner=False)
def generate_recommendations_gemini(df: pd.DataFrame, income: int = 0, max_rows: int = 800) -> str:
    """

    Ask Gemini for:

      - Monthly budget planning

      - Suggestions to reduce unnecessary spending

      - Personalized financial advice

    Output expected as Markdown with clearly labeled sections.

    """
    if not GENAI_READY:
        return "⚠️ LLM not configured. Set GEMINI_API_KEY to enable AI insights."

    try:
        context_json = build_llm_context(df, max_rows=max_rows)
        system_msg = (
            "You are a practical, precise personal finance advisor. Use the provided JSON transactions to compute figures."
        )
        user_instructions = {
            "goals": [
                "1) Provide a monthly budget plan (monthly target per category and total monthly budget).",
                "2) Suggest concrete ways to reduce unnecessary or recurring spending with estimated ₹ savings.",
                "3) Provide 3 personalized financial recommendations (e.g., emergency fund, SIPs, debt paydown), with estimated impact in ₹ or % where possible."
            ],
            "notes": "Use transaction data strictly from DATA(JSON). If income is provided, use it for savings calculations; otherwise infer income from credits.",
            "output_format": {
                "sections": ["Monthly Budget Plan", "Reduce Spending Suggestions", "Personalized Advice"],
                "format": "Markdown with bullet lists and numeric estimates. Keep it concise."
            },
            "user_income": income
        }

        prompt = (
            f"SYSTEM:\n{system_msg}\n\n"
            f"DATA(JSON):\n{context_json}\n\n"
            f"INSTRUCTIONS:\n{json.dumps(user_instructions, ensure_ascii=False)}\n\n"
            "Produce the response in Markdown with the three sections exactly as in output_format."
        )

        # Use the configured genai client
        model = genai.GenerativeModel(GEMINI_MODEL_NAME)
        resp = model.generate_content(prompt)
        return (getattr(resp, 'text', None) or resp or "").strip() if resp else "⚠️ Empty response from LLM."

    except Exception as e:
        err_str = str(e)
        if "429" in err_str or "quota" in err_str.lower():
            return "⚠️ LLM Quota Exceeded. You've made too many requests. Please try again later."
        # Special-case for blocked prompts if SDK provides such exception
        try:
            BlockedExc = getattr(genai.types, 'generation_types', None)
            # if a specific exception type is available we could catch it above
        except Exception:
            BlockedExc = None
        return f"⚠️ LLM error: {err_str}"

# -----------------------
# UI: File upload, parse, analyze, LLM button
# -----------------------
uploaded_files = st.file_uploader(
    "📂 Upload one or more PDF statements",
    type=["pdf"],
    accept_multiple_files=True
)

if uploaded_files:
    files_payload = [(f.name, f.read()) for f in uploaded_files]
    st.success(f"✅ {len(files_payload)} file(s) uploaded.")

    if enable_ocr and not OCR_AVAILABLE:
        st.warning("OCR requested but OCR libraries (pytesseract/pdf2image) are not available. Parsing will proceed without OCR.")

    with st.spinner("📄 Extracting & parsing transactions..."):
        df = extract_and_parse(files_payload, use_ocr=(enable_ocr and OCR_AVAILABLE))

    if df.empty:
        st.error("No transactions parsed. Try enabling OCR or verify the PDF format.")
    else:
        st.subheader("📋 Parsed Transactions (preview)")
        st.dataframe(df.head(500), use_container_width=True)

        # Basic KPIs (ignore unknown)
        df_known = df[df["type"].isin(["debit", "credit"])].copy()
        total_exp = float(df_known.loc[df_known["type"] == "debit", "amount"].sum())
        total_inc = float(df_known.loc[df_known["type"] == "credit", "amount"].sum())
        kpi1, kpi2, kpi3 = st.columns(3)
        kpi1.metric("Total Expenses (₹)", f"{total_exp:,.0f}")
        kpi2.metric("Total Income (₹)", f"{total_inc:,.0f}")
        net = total_inc - total_exp
        kpi3.metric("Net (₹)", f"{net:,.0f}", delta=None)

        # Charts & downloads
        tab1, tab2, tab3 = st.tabs(["📆 Monthly", "🗂️ Categories", "🏷️ Merchants"])
        with tab1:
            if "month" in df_known.columns and df_known["month"].nunique() > 0:
                msum = df_known.groupby(["month", "type"]).amount.sum().unstack(fill_value=0)
                st.bar_chart(msum)
        with tab2:
            cat_sum = df_known[df_known["type"] == "debit"].groupby("category").amount.sum().sort_values(ascending=False)
            st.bar_chart(cat_sum)
        with tab3:
            mer_sum = df_known[df_known["type"] == "debit"].groupby("merchant").amount.sum().sort_values(ascending=False).head(20)
            st.bar_chart(mer_sum)

        st.download_button(
            label="⬇️ Download Parsed CSV",
            data=df.to_csv(index=False).encode("utf-8"),
            file_name="transactions_parsed.csv",
            mime="text/csv",
        )

        # Offline insights
        if show_offline_insights:
            st.subheader("📊 Offline Insights (no LLM)")
            off = compute_offline_insights(df, income=user_monthly_income)
            st.json(off)

        # LLM recommendations
        st.subheader("🧠 LLM Recommendations")
        if GENAI_READY:
            if st.button("Generate Recommendations (Gemini)"):
                with st.spinner("Generating recommendations via Gemini..."):
                    md = generate_recommendations_gemini(df, income=user_monthly_income, max_rows=int(chunk_limit))
                st.markdown(md or "⚠️ No insights returned.")
                st.download_button(
                    label="⬇️ Download Recommendations (Markdown)",
                    data=(md or "").encode("utf-8"),
                    file_name="financial_recommendations.md",
                    mime="text/markdown",
                )
                st.markdown('<div class="success-banner">🎉 Recommendations ready — use them to plan your finances!</div>', unsafe_allow_html=True)
                try:
                    st.balloons()
                except Exception:
                    pass
        else:
            st.info("Provide GEMINI_API_KEY in environment to enable AI-powered recommendations.")
else:
    st.info("Upload at least one PDF to begin.")