diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..21dbc61 --- /dev/null +++ b/Readme.md @@ -0,0 +1,68 @@ +Personel Finance chatbot using LLM 💰🤖 + +ersonal Finance Chatbot with Hybrid ML and LLM Approach Overview This project is a Personal Finance Chatbot designed to assist users with budgeting, expense tracking, savings prediction, and investment risk profiling. It integrates a hybrid approach combining traditional machine learning models for core analytics and Large Language Models LLMs powered by OpenAI for natural language interaction, delivering an intuitive and powerful financial assistant. + +Features Core Machine Learning Components Budget Categorization Model: Classifies user transactions into categories such as food, bills, entertainment, etc., using classifiers like Naive Bayes and Random Forest. + +Expense Anomaly Detection: Detects unusual or fraudulent spending behavior through models like Isolation Forest or One-Class SVM. + +Savings Prediction Model: Predicts monthly savings based on past spending patterns with regression models such as XGBoost or Linear Regression. + +Investment Risk Profiling: Clusters users into risk profiles (e.g., conservative, moderate, aggressive) using unsupervised learning (K-Means clustering). + +Large Language Model Component Provides a conversational interface where users can ask finance-related questions naturally. + +This project is a Streamlit-based interactive chatbot that allows users to upload and analyze personal finance data (CSV files). It uses FAISS for vector search, Groq LLM for natural language understanding, and custom calculation functions to provide insights, summaries, and visualizations. + +🚀 Features + +📂 Upload CSV (finance transactions or any dataset) + +🔎 Search & Query with LLM (ask natural language questions) + +📊 Charts & Visualizations using Plotly + +🧮 Custom Finance Calculations (e.g., total spend, savings, category breakdown) + +⚡ FAISS Indexing for semantic search in uploaded data + +🔑 Environment variables support with .env + +🛠️ Tech Stack + +Python 3.10+ + +Streamlit – UI + +Pandas – Data processing + +Plotly – Charts + +FAISS – Vector indexing + +Groq API – LLM queries + +Dotenv – Environment variables + + + +⚙️ Setup Instructions +1️⃣ Clone Repo +git clone https://github.com/your-username/finance-csv-chatbot.git +cd finance-csv-chatbot + +2️⃣ Create Virtual Environment +python -m venv venv +source venv/bin/activate # Mac/Linux +venv\Scripts\activate + +3️⃣ Set Environment Variables + +Create a .env file in the project root: + +GROQ_API_KEY="your_api_key_here" +DATA_PATH=".csv file" +MODEL_NAME="llama-3.1-70b-versatile" + +4️⃣ Run the App +streamlit run app.py \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..3df16be --- /dev/null +++ b/app.py @@ -0,0 +1,140 @@ +# app.py +import os +import pandas as pd +import streamlit as st +import plotly.express as px + +from finchat import ( + load_csv, ensure_datetime, + Embedder, build_vector_store, query_index, handle_query, + guess_amount_column, build_timeseries, + GROQ_DEFAULT_MODEL +) + +st.set_page_config(page_title="Finance Chatbot", page_icon="💬", layout="wide") +st.title("💬 Finance Chatbot ") +st.caption("Upload a CSV, ask questions, get calculations and charts. Uses FAISS for retrieval and Groq LLM for answers.") + +# --- Sidebar Settings --- +with st.sidebar: + st.header("⚙️ Settings") + groq_key = st.text_input("GROQ_API_KEY (optional)", type="password", help="Set to enable LLM answers.") + if groq_key: + os.environ["GROQ_API_KEY"] = groq_key + + embed_model_name = st.selectbox( + "Embedding model", + ["all-MiniLM-L6-v2", "all-MiniLM-L12-v2", "all-mpnet-base-v2"], + index=0 + ) + k_retrieval = st.slider("Top-K retrieval", 3, 20, 8) + use_llm = st.checkbox("Use Groq LLM", value=True) + llm_model = st.text_input("Groq Chat Model", value=GROQ_DEFAULT_MODEL) + st.markdown("---") + st.caption("Tip: Keep numeric column named `Amount` if possible for best auto-calculation.") + +# --- File Upload --- +file = st.file_uploader("Upload your finance CSV", type=["csv"]) +if file is None: + st.info("Upload a CSV to get started.") + st.stop() + +# --- Load CSV & detect date column --- +with st.spinner("Loading CSV…"): + df = load_csv(file) + date_col = ensure_datetime(df) + +st.success(f"Loaded {len(df):,} rows. Date column: `{date_col}`" if date_col else f"Loaded {len(df):,} rows.") + +with st.expander("Preview Data", expanded=False): + st.dataframe(df.head(50), use_container_width=True) + +# --- Build embeddings & FAISS index (cached) --- +@st.cache_resource(show_spinner=False) +def load_index(dataframe, model_name): + embedder = Embedder(model_name=model_name) + index, embeddings, df_processed = build_vector_store(dataframe, embedder) + return embedder, index, embeddings, df_processed + +# Non-blocking spinner + input +with st.spinner("Indexing for similarity search… this may take a moment"): + embedder, index, embeddings, df = load_index(df, embed_model_name) + + +# --- Query Input --- +st.subheader("Ask a question") +default_q = "What is the total Amount for groceries in 2024?" +query = st.text_input("Type your question", value=default_q, placeholder="e.g., plot monthly expenses, total spend at Amazon…") +go = st.button("Ask", type="primary") +if not go and query.strip() == "": + st.stop() + +if go: + with st.spinner("🤖 Thinking…"): + result = handle_query( + query=query, + df=df, + index=index, + embeddings=embeddings, + embedder=embedder, + k=k_retrieval, + llm_model=llm_model, + use_llm=use_llm + ) + + # --- Results --- + colA, colB = st.columns([2, 1]) + + # Column A: LLM, Calc, Table, Plot + with colA: + st.markdown(f"**Intent detected:** `{result['intent']}`") + if "llm_answer" in result and result["llm_answer"]: + st.markdown("#### 🧠 Answer") + st.write(result["llm_answer"]) + + # Calculations + if result["intent"] == "calc": + calc = result.get("calc", {}) + if calc and calc.get("ok"): + st.markdown("#### 🧮 Calculation") + st.write(f"**{calc['agg'].upper()}** of **{calc['target']}** on {calc['n_rows']} rows = **{calc['value']:.2f}**") + elif calc and not calc.get("ok"): + st.error(calc.get("error", "Could not compute.")) + + # Table intent + if result["intent"] == "table": + st.markdown("#### 📄 Matching Rows") + st.dataframe(result["table"], use_container_width=True, height=360) + + # Plot intent + if result["intent"] == "plot": + ts = result.get("timeseries") + if ts is None or ts.empty: + amt_col = guess_amount_column(df) + if amt_col: + st.warning("No date column found. Showing top totals by Category instead.") + if "Category" in df.columns: + topcat = df.groupby("Category")[amt_col].sum().reset_index().sort_values(amt_col, ascending=False).head(15) + fig = px.bar(topcat, x="Category", y=amt_col, title="Top Categories by Total") + st.plotly_chart(fig, use_container_width=True) + else: + st.info("No `Category` column to aggregate.") + else: + st.info("No numeric column to plot.") + else: + fig = px.line(ts, x=ts.columns[0], y="Amount", title="Monthly Total Amount") + st.plotly_chart(fig, use_container_width=True) + + # Column B: Retrieved Context + with colB: + st.markdown("#### 🔎 Retrieved Context") + top_k = len(result["retrieved_texts"]) + for i in range(top_k): + score = float(result["retrieved_scores"][i]) + with st.expander(f"Row {i+1} (score {score:.3f})"): + st.code(result["retrieved_texts"][i]) + st.dataframe(result["retrieved_rows"].iloc[[i]], use_container_width=True) + + st.markdown("---") + st.caption("This app uses FAISS for similarity search over row text, a SentenceTransformer for embeddings, " + "and Groq for LLM answers constrained to retrieved context.") diff --git a/finchat.py b/finchat.py new file mode 100644 index 0000000..f0651a2 --- /dev/null +++ b/finchat.py @@ -0,0 +1,365 @@ +# finchat.py +# Optimized backend for Finance CSV Chatbot (Groq + FAISS + Streamlit) +# Supports batch embeddings, caching, and large CSVs (~100k+ rows) + +# finchat.py +from __future__ import annotations +import os +import json +import re +import pickle +from typing import List, Optional, Tuple, Dict, Any + +import numpy as np +import pandas as pd +from sentence_transformers import SentenceTransformer +import faiss + +# Groq LLM (optional) +try: + from groq import Groq +except Exception: + Groq = None + +# ---------------------- Data Loading ---------------------- + +NUMERIC_GUESS_COLS = {"amount", "value", "price", "debit", "credit", "balance"} +DATE_GUESS_COLS = {"date", "datetime", "timestamp", "time"} + +def load_csv(file_like) -> pd.DataFrame: + if isinstance(file_like, (str, os.PathLike)): + df = pd.read_csv(file_like) + else: + df = pd.read_csv(file_like) + + df.columns = [c.strip().replace("\n", " ") for c in df.columns] + + for col in df.columns: + low = col.lower() + if any(k in low for k in DATE_GUESS_COLS): + try: + df[col] = pd.to_datetime(df[col]) + except Exception: + pass + if any(k in low for k in NUMERIC_GUESS_COLS): + try: + df[col] = pd.to_numeric(df[col]) + except Exception: + pass + return df + +def ensure_datetime(df: pd.DataFrame) -> Optional[str]: + for col in df.columns: + if pd.api.types.is_datetime64_any_dtype(df[col]): + return col + if col.lower() in DATE_GUESS_COLS: + try: + parsed = pd.to_datetime(df[col], errors='coerce') + if parsed.notna().mean() > 0.8: + df[col] = parsed + return col + except Exception: + pass + return None + +# ---------------------- Text Corpus ---------------------- + +def build_row_text(row: pd.Series, text_cols: List[str]) -> str: + parts = [] + for c in text_cols: + v = row.get(c, "") + if pd.isna(v): + continue + parts.append(f"{c}: {v}") + return " | ".join(parts) + +def make_corpus(df: pd.DataFrame, text_cols: Optional[List[str]] = None, max_chars: int = 500) -> List[str]: + if not text_cols: + text_cols = [c for c in df.columns if pd.api.types.is_string_dtype(df[c])] + if not text_cols: + text_cols = list(df.columns) + corpus = [] + for _, row in df.iterrows(): + txt = build_row_text(row, text_cols) + if len(txt) > max_chars: + txt = txt[:max_chars] + "…" + corpus.append(txt) + return corpus + +# ---------------------- Embeddings + FAISS ---------------------- + +class Embedder: + def __init__(self, model_name: str = "all-MiniLM-L6-v2"): + self.model_name = model_name + self.model = SentenceTransformer(model_name) + + def encode(self, texts: List[str], batch_size: int = 512) -> np.ndarray: + """ + Encode texts in batches for faster processing with large CSVs. + """ + embeddings_list = [] + for i in range(0, len(texts), batch_size): + batch = texts[i:i+batch_size] + batch_emb = self.model.encode(batch, show_progress_bar=False, normalize_embeddings=True) + embeddings_list.append(batch_emb.astype("float32")) + return np.vstack(embeddings_list) + + +def build_vector_store(df: pd.DataFrame, embedder: Embedder, text_cols: Optional[List[str]] = None, cache_path: str = "embed_cache.pkl"): + """ + Build FAISS index from dataframe with batch embeddings and optional caching. + """ + if os.path.exists(cache_path): + with open(cache_path, "rb") as f: + index, embeddings = pickle.load(f) + print("Loaded cached embeddings.") + else: + corpus = make_corpus(df, text_cols=text_cols) + embeddings = embedder.encode(corpus) + dim = embeddings.shape[1] + index = faiss.IndexFlatIP(dim) + index.add(embeddings) + with open(cache_path, "wb") as f: + pickle.dump((index, embeddings), f) + print("Embeddings built and cached.") + + return index, embeddings, df + +def query_index(index: faiss.IndexFlatIP, embeddings: np.ndarray, df: pd.DataFrame, query: str, embedder: Embedder, k: int = 6) -> Tuple[pd.DataFrame, List[str], np.ndarray]: + qvec = embedder.encode([query]) + scores, idxs = index.search(qvec, k) + idxs = idxs[0] + scores = scores[0] + rows = df.iloc[idxs].copy() + texts = [make_row_text(df.iloc[i]) for i in idxs] + return rows, texts, scores + +def make_row_text(row: pd.Series) -> str: + return " | ".join(f"{c}: {row[c]}" for c in row.index if pd.notna(row[c])) + +# ---------------------- Intent Detection ---------------------- + +INTENT_RULES: Dict[str, List[str]] = { + "plot": ["plot", "graph", "chart", "visualize", "trend", "time series"], + "calc": ["sum", "total", "average", "avg", "mean", "min", "max", "count", "median"], + "table": ["show rows", "list", "table", "records", "top", "filter", "where"], +} + +def detect_intent(query: str) -> str: + q = query.lower() + for intent, keys in INTENT_RULES.items(): + if any(k in q for k in keys): + return intent + return "qa" + +# ---------------------- Calculations ---------------------- + +AGG_ALIASES = { + "sum": "sum", "total": "sum", + "average": "mean", "avg": "mean", "mean": "mean", + "min": "min", "max": "max", "median": "median", "count": "count" +} + +def find_numeric_cols(df: pd.DataFrame) -> List[str]: + return [c for c in df.columns if pd.api.types.is_numeric_dtype(df[c])] + +# parse_simple_calc() and run_calc() same as old code +# ---------------------- Plot Helpers ---------------------- + +def guess_amount_column(df: pd.DataFrame) -> Optional[str]: + for name in ["Amount", "amount", "Value", "Debit", "Credit"]: + if name in df.columns and pd.api.types.is_numeric_dtype(df[name]): + return name + nums = find_numeric_cols(df) + return nums[0] if nums else None + +def build_timeseries(df: pd.DataFrame, amount_col: Optional[str] = None, date_col: Optional[str] = None) -> Optional[pd.DataFrame]: + date_col = date_col or ensure_datetime(df) + if not date_col: + return None + amount_col = amount_col or guess_amount_column(df) + if not amount_col: + return None + ts = ( + df.dropna(subset=[date_col]) + .groupby(pd.Grouper(key=date_col, freq="M"))[amount_col] + .sum() + .reset_index() + .rename(columns={amount_col: "Amount"}) + ) + return ts + +# ---------------------- Groq LLM ---------------------- + +GROQ_DEFAULT_MODEL = "llama-3.1-8b-instant" + +def groq_client() -> Optional[Any]: + api_key = os.getenv("GROQ_API_KEY") + if not api_key or Groq is None: + return None + return Groq(api_key=api_key) + +SYS_PROMPT = """You are a finance CSV assistant. +Only answer using the supplied context snippets. If the user asks outside the CSV, say you don't have that data. +Be concise. Include short reasoning when relevant. +""" + +def answer_with_llm(question: str, context_chunks: List[str], model: str = GROQ_DEFAULT_MODEL, temperature: float = 0.1) -> str: + client = groq_client() + if client is None: + return "LLM is not configured. Please set GROQ_API_KEY." + context = "\n\n---\n".join(context_chunks) + msgs = [ + {"role": "system", "content": SYS_PROMPT}, + {"role": "user", "content": f"CONTEXT:\n{context}\n\nQUESTION:\n{question}"} + ] + resp = client.chat.completions.create( + model=model, + messages=msgs, + temperature=temperature, + ) + return resp.choices[0].message.content.strip() + + +# --------------------------- +# Simple Calculation Parser +# --------------------------- + +def parse_simple_calc(query: str, df): + """ + Parse a simple calculation query like: + - 'total spend at amazon' + - 'average spend on groceries' + - 'max transaction at walmart' + Returns a recipe dict with {op, column, filter} + """ + query = query.lower() + recipe = {"op": None, "column": "Amount", "filter": None} + + if "total" in query or "sum" in query: + recipe["op"] = "sum" + elif "average" in query or "mean" in query or "avg" in query: + recipe["op"] = "mean" + elif "max" in query or "maximum" in query: + recipe["op"] = "max" + elif "min" in query or "minimum" in query: + recipe["op"] = "min" + elif "count" in query or "how many" in query: + recipe["op"] = "count" + + # Try to detect vendor/merchant filter (like 'amazon', 'walmart') + for merchant in df["Description"].unique(): + if merchant.lower() in query: + recipe["filter"] = merchant + break + + return recipe + + + +def run_calc(df, recipe): + """ + Run a simple calculation (sum, avg, count, etc.) on the dataframe + based on the recipe parsed from the query. + """ + if not recipe or "operation" not in recipe: + return {"error": "Invalid recipe"} + + op = recipe["operation"] + column = recipe.get("column", "Amount") # default to Amount column + filt = recipe.get("filter", None) + + # Apply filter if provided + if filt: + try: + for col, val in filt.items(): + df = df[df[col].astype(str).str.contains(str(val), case=False, na=False)] + except Exception as e: + return {"error": f"Filter failed: {e}"} + + # Perform calculation + if op == "sum": + value = df[column].sum() + elif op == "avg": + value = df[column].mean() + elif op == "count": + value = df[column].count() + elif op == "max": + value = df[column].max() + elif op == "min": + value = df[column].min() + else: + return {"error": f"Unknown operation: {op}"} + + return {"operation": op, "column": column, "result": float(value)} + + + +# ---------------------- Query Orchestrator ---------------------- + +def handle_query( + query: str, + df: pd.DataFrame, + index: faiss.IndexFlatIP, + embeddings: np.ndarray, + embedder: Embedder, + k: int = 6, + llm_model: str = GROQ_DEFAULT_MODEL, + use_llm: bool = True +) -> dict: + """ + Process a user query: detect intent, retrieve relevant rows, compute calc/plot/table, optionally ask LLM. + """ + + intent = detect_intent(query) + result: Dict[str, Any] = {"intent": intent} + + # Retrieve relevant rows + rows, texts, scores = query_index(index, embeddings, df, query, embedder, k=k) + result["retrieved_rows"] = rows + result["retrieved_texts"] = texts + result["retrieved_scores"] = scores + + # Calculation intent + if intent == "calc": + from copy import deepcopy + recipe = parse_simple_calc(query, df) + calc = run_calc(df, recipe) + result["calc"] = calc + + if use_llm: + ctx = texts[:3] + calc_text = json.dumps(calc, indent=2) + llm_q = f"User asked: {query}\nComputed result JSON: {calc_text}\nExplain briefly." + result["llm_answer"] = answer_with_llm(llm_q, ctx, model=llm_model) + return result + + # Plot intent + if intent == "plot": + ts = build_timeseries(df) + result["timeseries"] = ts + if use_llm: + result["llm_answer"] = answer_with_llm( + f"User asked to visualize: {query}\nDescribe trends in the time series.", + texts[:4], + model=llm_model + ) + return result + + # Table intent + if intent == "table": + result["table"] = rows + if use_llm: + result["llm_answer"] = answer_with_llm( + f"User asked: {query}\nSummarize the relevant rows.", + texts[:6], + model=llm_model + ) + return result + + # Default: QA + if use_llm: + result["llm_answer"] = answer_with_llm(query, texts[:8], model=llm_model) + else: + result["llm_answer"] = "LLM disabled." + return result