Spaces:
Sleeping
Sleeping
GitHub Actions commited on
Commit ·
17a78b5
0
Parent(s):
Deploy to HF Spaces
Browse files- .env.example +51 -0
- .github/workflows/deploy-hf.yml +25 -0
- .gitignore +97 -0
- .hf/metadata.yml +12 -0
- CLAUDE.md +114 -0
- README.md +183 -0
- app.py +44 -0
- llms.txt +76 -0
- packages.txt +1 -0
- pyproject.toml +47 -0
- requirements.txt +365 -0
- scripts/evaluators.py +71 -0
- scripts/experiment_table_qa.py +222 -0
- scripts/run_eval.py +148 -0
- scripts/sanitize_agent_export.py +240 -0
- scripts/seed_demo_db.sql +1091 -0
- src/__init__.py +0 -0
- src/agent/__init__.py +3 -0
- src/agent/graph.py +28 -0
- src/agent/nodes.py +194 -0
- src/agent/prompts.py +406 -0
- src/agent/state.py +7 -0
- src/config.py +96 -0
- src/db/__init__.py +0 -0
- src/db/connection.py +54 -0
- src/tools/__init__.py +21 -0
- src/tools/account_balance.py +48 -0
- src/tools/all_accounts.py +38 -0
- src/tools/create_transaction.py +144 -0
- src/tools/delete_transaction.py +91 -0
- src/tools/finance_query.py +32 -0
- src/tools/generate_chart.py +212 -0
- src/tools/recent_transactions.py +53 -0
- src/tools/spending_by_category.py +54 -0
- src/tools/update_transaction.py +157 -0
- src/ui.py +553 -0
- tests/__init__.py +0 -0
- uv.lock +0 -0
.env.example
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================================
|
| 2 |
+
# LLM Provider Configuration
|
| 3 |
+
# ============================================================================
|
| 4 |
+
# Choose your provider: openai, anthropic, google, huggingface
|
| 5 |
+
LLM_PROVIDER=openai
|
| 6 |
+
|
| 7 |
+
# Set the API key for your chosen provider (only one needed)
|
| 8 |
+
OPENAI_API_KEY=sk-...
|
| 9 |
+
ANTHROPIC_API_KEY=sk-ant-...
|
| 10 |
+
GOOGLE_API_KEY=AI...
|
| 11 |
+
HF_TOKEN=hf_...
|
| 12 |
+
|
| 13 |
+
# Optional: override the default model for your provider
|
| 14 |
+
# MODEL_NAME=gpt-4o
|
| 15 |
+
# MODEL_MAX_TOKENS=512
|
| 16 |
+
|
| 17 |
+
# HuggingFace-specific: inference provider (together, sambanova, etc.)
|
| 18 |
+
# HF_INFERENCE_PROVIDER=together
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ============================================================================
|
| 22 |
+
# Database Configuration (PostgreSQL)
|
| 23 |
+
# ============================================================================
|
| 24 |
+
DB_HOST=localhost
|
| 25 |
+
DB_PORT=5432
|
| 26 |
+
DB_NAME=cashy_demo
|
| 27 |
+
DB_USER=financial_advisor
|
| 28 |
+
DB_PASSWORD=
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ============================================================================
|
| 32 |
+
# LangSmith Configuration (optional — for tracing and evaluation)
|
| 33 |
+
# ============================================================================
|
| 34 |
+
LANGSMITH_TRACING=true
|
| 35 |
+
LANGSMITH_API_KEY=
|
| 36 |
+
LANGSMITH_PROJECT=cashy-financial-advisor
|
| 37 |
+
LANGSMITH_ENDPOINT=https://api.smith.langchain.com
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ============================================================================
|
| 41 |
+
# Application Settings
|
| 42 |
+
# ============================================================================
|
| 43 |
+
# App mode: "demo" (seeded showcase data) or "personal" (your real financial data)
|
| 44 |
+
APP_MODE=personal
|
| 45 |
+
|
| 46 |
+
# Override database names per mode (optional — defaults shown below)
|
| 47 |
+
# DB_NAME_DEMO=cashy_demo
|
| 48 |
+
# DB_NAME_PERSONAL=financial_db
|
| 49 |
+
|
| 50 |
+
ENVIRONMENT=development
|
| 51 |
+
DEBUG=true
|
.github/workflows/deploy-hf.yml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy to HuggingFace Space
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
deploy:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
steps:
|
| 12 |
+
- uses: actions/checkout@v4
|
| 13 |
+
|
| 14 |
+
- name: Prepare and push to HuggingFace Space
|
| 15 |
+
env:
|
| 16 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 17 |
+
run: |
|
| 18 |
+
git config user.email "actions@github.com"
|
| 19 |
+
git config user.name "GitHub Actions"
|
| 20 |
+
cat .hf/metadata.yml README.md > README.tmp && mv README.tmp README.md
|
| 21 |
+
rm -rf docs/*.gif docs/*.mp4
|
| 22 |
+
git checkout --orphan hf-deploy
|
| 23 |
+
git add -A
|
| 24 |
+
git commit -m "Deploy to HF Spaces"
|
| 25 |
+
git push --force https://SeasonalFall84:$HF_TOKEN@huggingface.co/spaces/SeasonalFall84/cashy hf-deploy:main
|
.gitignore
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
share/python-wheels/
|
| 20 |
+
*.egg-info/
|
| 21 |
+
.installed.cfg
|
| 22 |
+
*.egg
|
| 23 |
+
MANIFEST
|
| 24 |
+
|
| 25 |
+
# Virtual Environments
|
| 26 |
+
venv/
|
| 27 |
+
env/
|
| 28 |
+
ENV/
|
| 29 |
+
env.bak/
|
| 30 |
+
venv.bak/
|
| 31 |
+
.venv/
|
| 32 |
+
|
| 33 |
+
# PyCharm
|
| 34 |
+
.idea/
|
| 35 |
+
|
| 36 |
+
# VS Code
|
| 37 |
+
.vscode/
|
| 38 |
+
|
| 39 |
+
# Environment variables
|
| 40 |
+
.env
|
| 41 |
+
.env.local
|
| 42 |
+
.env.*.local
|
| 43 |
+
|
| 44 |
+
# LangSmith / LangChain
|
| 45 |
+
.langsmith/
|
| 46 |
+
langsmith_cache/
|
| 47 |
+
|
| 48 |
+
# PostgreSQL
|
| 49 |
+
*.sql.backup
|
| 50 |
+
*.dump
|
| 51 |
+
|
| 52 |
+
# Logs
|
| 53 |
+
*.log
|
| 54 |
+
logs/
|
| 55 |
+
*.log.*
|
| 56 |
+
|
| 57 |
+
# OS
|
| 58 |
+
.DS_Store
|
| 59 |
+
Thumbs.db
|
| 60 |
+
|
| 61 |
+
# Testing
|
| 62 |
+
.pytest_cache/
|
| 63 |
+
.coverage
|
| 64 |
+
htmlcov/
|
| 65 |
+
.tox/
|
| 66 |
+
.nox/
|
| 67 |
+
|
| 68 |
+
# Jupyter Notebook
|
| 69 |
+
.ipynb_checkpoints
|
| 70 |
+
|
| 71 |
+
# mypy
|
| 72 |
+
.mypy_cache/
|
| 73 |
+
.dmypy.json
|
| 74 |
+
dmypy.json
|
| 75 |
+
|
| 76 |
+
# Reflection reports (optional - uncomment if you don't want to track these)
|
| 77 |
+
# reflection_report_*.md
|
| 78 |
+
|
| 79 |
+
# Temporary files
|
| 80 |
+
*.tmp
|
| 81 |
+
*.temp
|
| 82 |
+
.cache/
|
| 83 |
+
|
| 84 |
+
# Claude Code project files
|
| 85 |
+
.claude/
|
| 86 |
+
|
| 87 |
+
# Developer documentation (internal use only)
|
| 88 |
+
dev/
|
| 89 |
+
|
| 90 |
+
# Old Langflow agent exports (kept locally for reference)
|
| 91 |
+
agent_versions/
|
| 92 |
+
|
| 93 |
+
# Eval cases (local only)
|
| 94 |
+
eval_cases/
|
| 95 |
+
|
| 96 |
+
# VBA macros (local only)
|
| 97 |
+
macros/
|
.hf/metadata.yml
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Cashy - AI Financial Advisor
|
| 3 |
+
emoji: "\U0001F4B0"
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "6.5.1"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: AI-powered personal finance advisor with BYOK LLM support
|
| 12 |
+
---
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Claude Code Instructions for Cashy Project
|
| 2 |
+
|
| 3 |
+
## Project Summary
|
| 4 |
+
|
| 5 |
+
Cashy is an AI-powered personal financial advisor built with LangGraph + LangChain + Gradio + PostgreSQL. It queries a financial database to answer questions about accounts, transactions, and spending. Supports multiple LLM providers (OpenAI, Anthropic, Google, HuggingFace) plus a free tier for zero-config demo access.
|
| 6 |
+
|
| 7 |
+
**Tech Stack**: LangGraph (orchestration) + LangChain (tools + LLM abstractions) + Gradio (UI) + PostgreSQL (data + checkpoints) + LangSmith (tracing)
|
| 8 |
+
|
| 9 |
+
## How to Run
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
# Configure your LLM provider in .env (see .env.example)
|
| 13 |
+
# Personal mode (default — connects to financial_db):
|
| 14 |
+
uv run python app.py
|
| 15 |
+
|
| 16 |
+
# Demo mode (seeded showcase data in cashy_demo):
|
| 17 |
+
APP_MODE=demo uv run python app.py
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
## Project Structure
|
| 21 |
+
|
| 22 |
+
```
|
| 23 |
+
cashy-poc/
|
| 24 |
+
├── app.py # Entry point (logging, checkpointer, Gradio)
|
| 25 |
+
├── pyproject.toml # Dependencies (managed by uv)
|
| 26 |
+
├── .env.example # Template for environment variables
|
| 27 |
+
├── src/
|
| 28 |
+
│ ├── config.py # Pydantic BaseSettings — app mode, multi-provider config
|
| 29 |
+
│ ├── ui.py # Gradio Blocks UI (chat, sidebar, thread history, themes)
|
| 30 |
+
│ ├── agent/
|
| 31 |
+
│ │ ├── graph.py # LangGraph StateGraph factory
|
| 32 |
+
│ │ ├── nodes.py # LLM factory (5 providers incl. free-tier), call_model, should_continue
|
| 33 |
+
│ │ ├── prompts.py # System prompts (demo + personal modes)
|
| 34 |
+
│ │ └── state.py # AgentState (extends MessagesState)
|
| 35 |
+
│ ├── tools/
|
| 36 |
+
│ │ ├── __init__.py # Exports all_tools list
|
| 37 |
+
│ │ ├── finance_query.py # @tool — custom SELECT queries
|
| 38 |
+
│ │ ├── account_balance.py
|
| 39 |
+
│ │ ├── recent_transactions.py
|
| 40 |
+
│ │ ├── spending_by_category.py
|
| 41 |
+
│ │ ├── all_accounts.py
|
| 42 |
+
│ │ ├── create_transaction.py
|
| 43 |
+
│ │ └── generate_chart.py # @tool — matplotlib charts (bar, pie, line, grouped)
|
| 44 |
+
│ └── db/
|
| 45 |
+
│ └── connection.py # psycopg2 context manager
|
| 46 |
+
├── eval_cases/ # Evaluation test cases (JSON)
|
| 47 |
+
├── scripts/
|
| 48 |
+
│ ├── run_eval.py # Eval runner
|
| 49 |
+
│ ├── seed_demo_db.sql # Demo DB schema + seed data
|
| 50 |
+
│ └── sanitize_agent_export.py
|
| 51 |
+
├── dev/ # Developer documentation
|
| 52 |
+
│ ├── active/ # In-progress task docs
|
| 53 |
+
│ └── archive/ # Completed task docs
|
| 54 |
+
└── .env # Credentials (not committed)
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## Key Technical Details
|
| 58 |
+
|
| 59 |
+
- **Package manager**: uv (not pip). Use `uv sync` and `uv run`.
|
| 60 |
+
- **LLM providers**: OpenAI, Anthropic, Google, HuggingFace, **free-tier** — configured via `LLM_PROVIDER` env var or auto-detected from API keys (priority: openai > anthropic > google > huggingface). Free-tier is auto-detected in demo mode when `HF_TOKEN` is the only key set.
|
| 61 |
+
- **Free-tier provider**: Uses the Space owner's `HF_TOKEN` (captured at startup as `_SPACE_HF_TOKEN` in nodes.py). Locked to `Qwen/Qwen2.5-7B-Instruct` — no model override, no inference provider param (HF auto-routes). UI hides API key input, Save button, and model textbox when selected. Responses include a disclaimer nudging users toward paid providers.
|
| 62 |
+
- **Default models**: gpt-5-mini, claude-sonnet-4-20250514, gemini-2.5-flash, Llama-3.3-70B-Instruct, Qwen2.5-7B-Instruct (free-tier). Users can override via the model name textbox in the sidebar (except free-tier).
|
| 63 |
+
- **HF inference providers**: When HuggingFace is selected, a dropdown lets users pick from 14 inference providers (cerebras, groq, together, etc.).
|
| 64 |
+
- **Lazy imports**: Provider packages are imported inside if-branches in `nodes.py` to avoid errors when only one provider is installed.
|
| 65 |
+
- **ChatHuggingFace** does NOT support `with_structured_output()` — use `bind_tools()`.
|
| 66 |
+
- **Latin-1 sanitization**: HuggingFace's HTTP transport uses latin-1 encoding. `_sanitize_for_latin1()` in nodes.py strips non-latin-1 chars from messages and tool descriptions before sending to HF. Applies to both `huggingface` and `free-tier` providers.
|
| 67 |
+
- **Prompt guidelines**: System prompts include "NEVER show SQL queries in responses" and "Always execute tools immediately" — critical for smaller models (7B) that otherwise dump raw SQL.
|
| 68 |
+
- **Memory**: LangGraph MessagesState (session) + PostgresSaver (cross-session via psycopg v3).
|
| 69 |
+
- **Tools use psycopg2**, checkpointer uses psycopg v3 — both coexist in pyproject.toml.
|
| 70 |
+
- **System prompt**: Has `{today}` and `{year}` placeholders injected dynamically in `call_model()`.
|
| 71 |
+
- **9 tools**: finance_db_query, get_account_balance, get_recent_transactions, get_spending_by_category, get_all_accounts, create_transaction, update_transaction, delete_transaction, generate_chart.
|
| 72 |
+
- **Human-in-the-loop**: Write tools (create/update/delete) use LangGraph `interrupt()` for user confirmation before DB writes. UI handles via `pending_interrupt` state + `Command(resume=...)`.
|
| 73 |
+
- **Chart rendering**: `generate_chart` saves matplotlib PNGs to /tmp. UI scans ToolMessages for `chart_path` and renders inline via Gradio's `{"path": ...}` format.
|
| 74 |
+
- **Session memory**: `gr.State` holds a per-session `thread_id` so follow-up messages share LangGraph conversation context.
|
| 75 |
+
- **Thread management**: "New Chat" button + "Chat History" accordion to start fresh or resume past threads.
|
| 76 |
+
- **Transfers**: `get_recent_transactions` collapses transfer entries into single rows with `from_account`/`to_account`.
|
| 77 |
+
- **App mode**: `APP_MODE=demo` (cashy_demo DB, Glass indigo theme, freelancer prompt) or `APP_MODE=personal` (financial_db, Default theme, generic prompt). Default: personal.
|
| 78 |
+
- **Themes**: Demo uses `gr.themes.Glass(primary_hue="indigo")`, personal uses `gr.themes.Default()`.
|
| 79 |
+
|
| 80 |
+
## Database
|
| 81 |
+
|
| 82 |
+
- **Host**: localhost:5432, **User**: financial_advisor
|
| 83 |
+
- **Demo DB**: `cashy_demo` — English, US freelancer, USD. Seed: `scripts/seed_demo_db.sql`
|
| 84 |
+
- **Personal DB**: `financial_db` — user's real financial data, same schema
|
| 85 |
+
- **Schema**: 11 tables + 8 views (see `dev/DATABASE_SCHEMA.md`)
|
| 86 |
+
- Key tables: accounts, transactions, transaction_entries, categories, budgets
|
| 87 |
+
- Key views: v_transaction_details, v_monthly_spending, v_account_summary
|
| 88 |
+
|
| 89 |
+
## Development Guidelines
|
| 90 |
+
|
| 91 |
+
- Prefer editing existing files over creating new ones
|
| 92 |
+
- Run code to validate — `uv run python app.py` or Python REPL
|
| 93 |
+
- Keep `dev/active/` task docs updated for complex work
|
| 94 |
+
- Never commit `.env` or files with credentials
|
| 95 |
+
- Use structured logging (`cashy.agent`, `cashy.tools`, `cashy.db`, `cashy.ui`)
|
| 96 |
+
|
| 97 |
+
## Rules
|
| 98 |
+
|
| 99 |
+
- **Python:** Always run with `uv run python`, never `python` or `python3` directly
|
| 100 |
+
- **Libraries:** Use Context7 MCP (`resolve-library-id` → `query-docs`) for external docs
|
| 101 |
+
- **Git commits:** Single-line commit message. Never include Co-Authored-By
|
| 102 |
+
- **Language:** English only (portfolio project targeting US/international clients)
|
| 103 |
+
- **Timezone:** Assume `America/Mexico_City` unless specified
|
| 104 |
+
- **Secrets:** NEVER hardcode API keys, tokens, passwords, or secrets in commands, code, or chat output. Always reference them via environment variables from `.env` files (e.g., `$API_KEY`). If a needed var isn't set, ask the user to add it to `.env` — never ask for the raw value
|
| 105 |
+
|
| 106 |
+
## Quick References
|
| 107 |
+
|
| 108 |
+
- **Live Demo**: https://huggingface.co/spaces/SeasonalFall84/cashy (deployed from `main`, demo mode)
|
| 109 |
+
- **Architecture**: `dev/ARCHITECTURE.md`
|
| 110 |
+
- **DB Schema**: `dev/DATABASE_SCHEMA.md`
|
| 111 |
+
- **Branches**: `dev/BRANCHES.md`
|
| 112 |
+
- **Troubleshooting**: `dev/TROUBLESHOOTING.md`
|
| 113 |
+
- **Eval cases**: `eval_cases/eval_cases_v1.json`
|
| 114 |
+
- **Env template**: `.env.example`
|
README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Cashy - AI Financial Advisor
|
| 3 |
+
emoji: "\U0001F4B0"
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "6.5.1"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: AI-powered personal finance advisor with BYOK LLM support
|
| 12 |
+
---
|
| 13 |
+
# Cashy - AI Financial Advisor
|
| 14 |
+
|
| 15 |
+
An AI-powered personal finance agent that answers natural language questions about accounts, transactions, spending, and budgets by querying a real PostgreSQL database. Built with LangGraph, LangChain, and your choice of LLM provider.
|
| 16 |
+
|
| 17 |
+
**[Try the live demo](https://huggingface.co/spaces/SeasonalFall84/cashy)** - no API key required (free tier included)
|
| 18 |
+
|
| 19 |
+
**[See the YT demo video](https://youtu.be/Ln5vl8dd5aI)**
|
| 20 |
+
|
| 21 |
+

|
| 22 |
+
|
| 23 |
+
## The Problem
|
| 24 |
+
|
| 25 |
+
Freelancers juggle multiple bank accounts, payment platforms, credit cards, and investment accounts. Tracking spending, comparing budgets, and answering simple financial questions means logging into several dashboards and doing mental math.
|
| 26 |
+
|
| 27 |
+
## The Solution
|
| 28 |
+
|
| 29 |
+
Cashy is a conversational AI agent that sits on top of your financial database. Ask a question in plain English - Cashy picks the right tool, queries the database, and gives you an accurate answer with real numbers.
|
| 30 |
+
|
| 31 |
+
## Features
|
| 32 |
+
|
| 33 |
+
- **Multi-provider LLM** - OpenAI, Anthropic, Google, HuggingFace, or free tier (auto-detected from API key)
|
| 34 |
+
- **BYOK (Bring Your Own Key)** - Paste your API key in the sidebar, switch providers on the fly
|
| 35 |
+
- **Free tier** - Zero-friction demo with Qwen 2.5 7B, no API key needed
|
| 36 |
+
- **9 specialized tools** - Account balances, transactions, spending analysis, budget tracking, CRUD operations, custom SQL, and chart generation
|
| 37 |
+
- **Human-in-the-loop** - Write operations (create/update/delete) require user confirmation before executing
|
| 38 |
+
- **Chart generation** - Bar, pie, line, and grouped comparison charts rendered inline via matplotlib
|
| 39 |
+
- **Persistent memory** - Conversation history stored in PostgreSQL via LangGraph checkpoints
|
| 40 |
+
- **Demo / Personal mode** - Switch between showcase data and your real finances via `APP_MODE`
|
| 41 |
+
- **Demo data included** - 11 accounts, 180+ transactions, 23 budgets for a US freelancer scenario
|
| 42 |
+
|
| 43 |
+
## Quick Setup
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
# 1. Clone and install
|
| 47 |
+
git clone https://github.com/MarioAderman/cashy-poc.git
|
| 48 |
+
cd cashy-poc
|
| 49 |
+
uv sync
|
| 50 |
+
|
| 51 |
+
# 2. Configure your LLM provider
|
| 52 |
+
cp .env.example .env
|
| 53 |
+
# Edit .env - paste your API key (OpenAI, Anthropic, Google, or HuggingFace)
|
| 54 |
+
|
| 55 |
+
# 3. Seed the demo database
|
| 56 |
+
createdb -U postgres cashy_demo
|
| 57 |
+
psql -U postgres -d cashy_demo -f scripts/seed_demo_db.sql
|
| 58 |
+
|
| 59 |
+
# 4. Run (demo mode with showcase data)
|
| 60 |
+
APP_MODE=demo uv run python app.py
|
| 61 |
+
|
| 62 |
+
# Or run with your own database (default)
|
| 63 |
+
uv run python app.py
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Open http://localhost:7860 - Cashy greets you with a welcome message and a reference card.
|
| 67 |
+
|
| 68 |
+
## Architecture
|
| 69 |
+
|
| 70 |
+
```
|
| 71 |
+
┌──────────────────────────────────────────────────────┐
|
| 72 |
+
│ Gradio UI (port 7860) │
|
| 73 |
+
│ Chat + BYOK sidebar + provider switcher │
|
| 74 |
+
└──────────────────────┬───────────────────────────────┘
|
| 75 |
+
│
|
| 76 |
+
┌──────────────────────▼───────────────────────────────┐
|
| 77 |
+
│ LangGraph Agent (StateGraph) │
|
| 78 |
+
│ START → agent → should_continue → tools → agent → END│
|
| 79 |
+
│ │ │
|
| 80 |
+
│ ┌────────────────────┼────────────────────────┐ │
|
| 81 |
+
│ │ LLM (configurable)│ ToolNode (9 tools) │ │
|
| 82 |
+
│ │ OpenAI / Anthropic │ via LangChain @tool │ │
|
| 83 |
+
│ │ Google / HuggingFace│ + interrupt() for │ │
|
| 84 |
+
│ │ Free tier (Qwen) │ write operations │ │
|
| 85 |
+
│ └────────────────────┴────────────────────────┘ │
|
| 86 |
+
└──────────────────────┬───────────────────────────────┘
|
| 87 |
+
│
|
| 88 |
+
┌──────────────┼──────────────┐
|
| 89 |
+
▼ ▼ ▼
|
| 90 |
+
┌──────────────┐ ┌──────────┐ ┌─────────────┐
|
| 91 |
+
│ PostgreSQL │ │LangSmith │ │ PostgreSQL │
|
| 92 |
+
│ (financial │ │(tracing) │ │ (checkpoints)│
|
| 93 |
+
│ data) │ │ │ │ │
|
| 94 |
+
└──────────────┘ └──────────┘ └────────────��┘
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## Tech Stack
|
| 98 |
+
|
| 99 |
+
| Component | Technology |
|
| 100 |
+
|---|---|
|
| 101 |
+
| Agent orchestration | LangGraph + LangChain |
|
| 102 |
+
| LLM | OpenAI, Anthropic, Google, HuggingFace, Free tier (configurable) |
|
| 103 |
+
| UI | Gradio Blocks (chat + BYOK sidebar) |
|
| 104 |
+
| Database | PostgreSQL (financial data + conversation checkpoints) |
|
| 105 |
+
| Tracing | LangSmith |
|
| 106 |
+
| Package manager | uv |
|
| 107 |
+
| Config | Pydantic Settings + .env (auto-detects provider from API key) |
|
| 108 |
+
|
| 109 |
+
## Configuration
|
| 110 |
+
|
| 111 |
+
Copy `.env.example` and set your preferred provider's API key. Cashy auto-detects which provider to use, or you can set `LLM_PROVIDER` explicitly.
|
| 112 |
+
|
| 113 |
+
| Variable | Purpose | Default |
|
| 114 |
+
|---|---|---|
|
| 115 |
+
| `APP_MODE` | `demo` or `personal` | `personal` |
|
| 116 |
+
| `LLM_PROVIDER` | Force a provider (optional) | Auto-detected from API key |
|
| 117 |
+
| `OPENAI_API_KEY` | OpenAI API key | - |
|
| 118 |
+
| `ANTHROPIC_API_KEY` | Anthropic API key | - |
|
| 119 |
+
| `GOOGLE_API_KEY` | Google AI API key | - |
|
| 120 |
+
| `HF_TOKEN` | HuggingFace token | - |
|
| 121 |
+
| `MODEL_NAME` | Override default model | Per-provider default |
|
| 122 |
+
| `DB_NAME_DEMO` | Demo database name | `cashy_demo` |
|
| 123 |
+
| `DB_NAME_PERSONAL` | Personal database name | `financial_db` |
|
| 124 |
+
| `LANGSMITH_API_KEY` | LangSmith tracing (optional) | - |
|
| 125 |
+
|
| 126 |
+
**Default models:** gpt-5-mini, claude-sonnet-4-20250514, gemini-2.5-flash, Llama-3.3-70B-Instruct, Qwen2.5-7B-Instruct (free tier)
|
| 127 |
+
|
| 128 |
+
## Tools
|
| 129 |
+
|
| 130 |
+
| Tool | Purpose |
|
| 131 |
+
|---|---|
|
| 132 |
+
| `finance_db_query` | Execute custom SELECT queries |
|
| 133 |
+
| `get_account_balance` | Get balance for a specific account |
|
| 134 |
+
| `get_recent_transactions` | Fetch recent transaction history |
|
| 135 |
+
| `get_spending_by_category` | Monthly spending by category |
|
| 136 |
+
| `get_all_accounts` | List all accounts with balances |
|
| 137 |
+
| `create_transaction` | Record new income/expense/transfer |
|
| 138 |
+
| `update_transaction` | Modify existing transactions |
|
| 139 |
+
| `delete_transaction` | Remove transactions |
|
| 140 |
+
| `generate_chart` | Bar, pie, line, grouped bar charts from SQL results |
|
| 141 |
+
|
| 142 |
+
## Project Structure
|
| 143 |
+
|
| 144 |
+
```
|
| 145 |
+
cashy-poc/
|
| 146 |
+
├── app.py # Entry point
|
| 147 |
+
├── pyproject.toml # Dependencies (managed by uv)
|
| 148 |
+
├── .env.example # Environment variable template
|
| 149 |
+
├── src/
|
| 150 |
+
│ ├── config.py # Multi-provider config, app mode, auto-detection
|
| 151 |
+
│ ├── ui.py # Gradio Blocks UI (chat, sidebar, BYOK, themes)
|
| 152 |
+
│ ├── agent/
|
| 153 |
+
│ │ ├── graph.py # LangGraph StateGraph factory
|
| 154 |
+
│ │ ├── nodes.py # LLM factory (5 providers), call_model, routing
|
| 155 |
+
│ │ ├── prompts.py # System prompts (demo + personal modes)
|
| 156 |
+
│ │ └── state.py # AgentState (extends MessagesState)
|
| 157 |
+
│ ├── tools/
|
| 158 |
+
│ │ ├── finance_query.py # Custom SQL SELECT queries
|
| 159 |
+
│ │ ├── account_balance.py
|
| 160 |
+
│ │ ├── recent_transactions.py
|
| 161 |
+
│ │ ├── spending_by_category.py
|
| 162 |
+
│ │ ├── all_accounts.py
|
| 163 |
+
│ │ ├── create_transaction.py
|
| 164 |
+
│ │ ├── update_transaction.py
|
| 165 |
+
│ │ ├── delete_transaction.py
|
| 166 |
+
│ │ └── generate_chart.py # matplotlib chart generation
|
| 167 |
+
│ └── db/
|
| 168 |
+
│ └── connection.py # psycopg2 context manager
|
| 169 |
+
└── scripts/
|
| 170 |
+
├── run_eval.py # Eval runner
|
| 171 |
+
└── seed_demo_db.sql # Demo database schema + seed data
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
## Prerequisites
|
| 175 |
+
|
| 176 |
+
- Python 3.10+
|
| 177 |
+
- PostgreSQL running locally
|
| 178 |
+
- [uv](https://docs.astral.sh/uv/) package manager
|
| 179 |
+
- At least one LLM API key (or use the free tier in demo mode)
|
| 180 |
+
|
| 181 |
+
## License
|
| 182 |
+
|
| 183 |
+
MIT
|
app.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from src.config import settings
|
| 3 |
+
from src.agent.graph import create_agent
|
| 4 |
+
from src.ui import create_ui
|
| 5 |
+
from langgraph.checkpoint.postgres import PostgresSaver
|
| 6 |
+
|
| 7 |
+
logging.basicConfig(
|
| 8 |
+
level=logging.DEBUG if settings.debug else logging.INFO,
|
| 9 |
+
format="%(asctime)s [%(name)-12s] %(levelname)-7s %(message)s",
|
| 10 |
+
datefmt="%H:%M:%S",
|
| 11 |
+
)
|
| 12 |
+
# Quiet noisy third-party loggers
|
| 13 |
+
for name in ("httpx", "httpcore", "urllib3", "hf_transfer", "gradio", "uvicorn",
|
| 14 |
+
"openai", "anthropic", "google", "google.auth", "google.generativeai",
|
| 15 |
+
"matplotlib", "psycopg", "psycopg.pool"):
|
| 16 |
+
logging.getLogger(name).setLevel(logging.WARNING)
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger("cashy.main")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def main():
|
| 22 |
+
provider = settings.resolved_provider
|
| 23 |
+
model_name = settings.model_name or "default"
|
| 24 |
+
logger.info("Mode: %s", settings.app_mode)
|
| 25 |
+
if provider:
|
| 26 |
+
logger.info("LLM: %s (%s)", provider, model_name)
|
| 27 |
+
else:
|
| 28 |
+
logger.warning("LLM: No API key configured — user must provide one via UI")
|
| 29 |
+
logger.info("Database: %s@%s:%s", settings.resolved_db_name, settings.db_host[:8] + "...", settings.db_port)
|
| 30 |
+
|
| 31 |
+
with PostgresSaver.from_conn_string(settings.database_url) as checkpointer:
|
| 32 |
+
checkpointer.setup()
|
| 33 |
+
logger.info("Checkpoint tables ready")
|
| 34 |
+
|
| 35 |
+
agent = create_agent(checkpointer=checkpointer)
|
| 36 |
+
logger.info("Agent graph compiled")
|
| 37 |
+
|
| 38 |
+
demo, theme = create_ui(agent)
|
| 39 |
+
logger.info("Launching Gradio on http://localhost:7860")
|
| 40 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, theme=theme)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
if __name__ == "__main__":
|
| 44 |
+
main()
|
llms.txt
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cashy - AI Financial Advisor
|
| 2 |
+
|
| 3 |
+
> AI-powered personal finance agent that queries a PostgreSQL database to answer natural language questions about accounts, transactions, spending, and budgets.
|
| 4 |
+
|
| 5 |
+
## Stack
|
| 6 |
+
|
| 7 |
+
- LangGraph (agent orchestration, StateGraph)
|
| 8 |
+
- LangChain (tool abstractions, LLM bindings)
|
| 9 |
+
- Gradio (web UI)
|
| 10 |
+
- PostgreSQL (financial data + conversation checkpoints)
|
| 11 |
+
- LangSmith (tracing)
|
| 12 |
+
- matplotlib (chart generation)
|
| 13 |
+
|
| 14 |
+
## LLM Providers
|
| 15 |
+
|
| 16 |
+
Supports OpenAI, Anthropic, Google, HuggingFace, and a free tier (Qwen 2.5 7B). Auto-detected from API key or set via LLM_PROVIDER env var. BYOK input in sidebar.
|
| 17 |
+
|
| 18 |
+
## Agent Architecture
|
| 19 |
+
|
| 20 |
+
LangGraph StateGraph: START -> call_model -> should_continue -> [tools | END]
|
| 21 |
+
- call_model: injects system prompt + invokes LLM with bound tools
|
| 22 |
+
- should_continue: routes to ToolNode if tool calls present, else END
|
| 23 |
+
- Memory: MessagesState (session) + PostgresSaver (cross-session)
|
| 24 |
+
|
| 25 |
+
## Tools (9)
|
| 26 |
+
|
| 27 |
+
- finance_db_query: execute custom SELECT queries
|
| 28 |
+
- get_account_balance: balance for a specific account
|
| 29 |
+
- get_recent_transactions: recent transaction history
|
| 30 |
+
- get_spending_by_category: monthly spending breakdown
|
| 31 |
+
- get_all_accounts: list all accounts with balances
|
| 32 |
+
- create_transaction: record new income/expense/transfer (requires confirmation)
|
| 33 |
+
- update_transaction: modify existing transactions (requires confirmation)
|
| 34 |
+
- delete_transaction: remove transactions (requires confirmation)
|
| 35 |
+
- generate_chart: bar, pie, line, grouped bar charts via matplotlib
|
| 36 |
+
|
| 37 |
+
Write tools use LangGraph interrupt() for human-in-the-loop confirmation.
|
| 38 |
+
|
| 39 |
+
## Key Files
|
| 40 |
+
|
| 41 |
+
- app.py: entry point (logging, checkpointer setup, Gradio launch)
|
| 42 |
+
- src/config.py: Pydantic BaseSettings, multi-provider config, auto-detection
|
| 43 |
+
- src/ui.py: Gradio Blocks UI (chat, BYOK sidebar, provider switcher, themes)
|
| 44 |
+
- src/agent/graph.py: LangGraph StateGraph factory
|
| 45 |
+
- src/agent/nodes.py: LLM factory (5 providers), call_model, should_continue
|
| 46 |
+
- src/agent/prompts.py: system prompts for demo and personal modes
|
| 47 |
+
- src/agent/state.py: AgentState (extends MessagesState)
|
| 48 |
+
- src/tools/__init__.py: exports all_tools list
|
| 49 |
+
- src/db/connection.py: psycopg2 context manager
|
| 50 |
+
- scripts/seed_demo_db.sql: demo database schema + seed data
|
| 51 |
+
|
| 52 |
+
## Modes
|
| 53 |
+
|
| 54 |
+
- demo: cashy_demo DB, Glass indigo theme, US freelancer persona, free tier available
|
| 55 |
+
- personal: financial_db, Default theme, generic financial advisor persona
|
| 56 |
+
|
| 57 |
+
## Database Schema
|
| 58 |
+
|
| 59 |
+
Key tables: accounts, transactions, transaction_entries, categories, budgets
|
| 60 |
+
Key views: v_transaction_details, v_monthly_spending, v_account_summary
|
| 61 |
+
11 tables + 8 views total. Demo DB: 11 accounts, 180+ transactions, 23 budgets (USD).
|
| 62 |
+
|
| 63 |
+
## Running
|
| 64 |
+
|
| 65 |
+
Requires Python 3.10+, PostgreSQL, uv package manager.
|
| 66 |
+
|
| 67 |
+
```
|
| 68 |
+
uv sync
|
| 69 |
+
cp .env.example .env # add your API key
|
| 70 |
+
APP_MODE=demo uv run python app.py # demo mode
|
| 71 |
+
uv run python app.py # personal mode
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
## Live Demo
|
| 75 |
+
|
| 76 |
+
https://huggingface.co/spaces/SeasonalFall84/cashy
|
packages.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
libpq-dev
|
pyproject.toml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "cashy"
|
| 3 |
+
version = "1.0.0"
|
| 4 |
+
description = "AI-powered financial advisor using LangGraph + LangChain + HuggingFace"
|
| 5 |
+
requires-python = ">=3.10"
|
| 6 |
+
dependencies = [
|
| 7 |
+
# Core agent framework
|
| 8 |
+
"langgraph>=0.2",
|
| 9 |
+
"langchain>=0.3",
|
| 10 |
+
"langchain-core>=0.3",
|
| 11 |
+
"langchain-huggingface>=0.1",
|
| 12 |
+
|
| 13 |
+
# LLM providers
|
| 14 |
+
"huggingface-hub>=0.20",
|
| 15 |
+
"langchain-openai>=0.3",
|
| 16 |
+
"langchain-anthropic>=0.3",
|
| 17 |
+
"langchain-google-genai>=2.0",
|
| 18 |
+
|
| 19 |
+
# Database (psycopg2 for tools, psycopg v3 for checkpointer)
|
| 20 |
+
"psycopg2-binary>=2.9",
|
| 21 |
+
"psycopg[binary]>=3.1",
|
| 22 |
+
"langgraph-checkpoint-postgres>=2.0",
|
| 23 |
+
|
| 24 |
+
# UI + charts
|
| 25 |
+
"gradio>=4.0",
|
| 26 |
+
"matplotlib>=3.8",
|
| 27 |
+
|
| 28 |
+
# Evaluation & tracing
|
| 29 |
+
"langsmith>=0.1",
|
| 30 |
+
|
| 31 |
+
# Config
|
| 32 |
+
"python-dotenv>=1.0",
|
| 33 |
+
"pydantic>=2.0",
|
| 34 |
+
"pydantic-settings>=2.0",
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
[project.optional-dependencies]
|
| 38 |
+
dev = [
|
| 39 |
+
"pytest>=7.4",
|
| 40 |
+
"pytest-cov>=4.0",
|
| 41 |
+
"ruff>=0.1",
|
| 42 |
+
]
|
| 43 |
+
experiment = [
|
| 44 |
+
"transformers",
|
| 45 |
+
"torch",
|
| 46 |
+
"pandas<3",
|
| 47 |
+
]
|
requirements.txt
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file was autogenerated by uv via the following command:
|
| 2 |
+
# uv export --no-hashes --no-dev
|
| 3 |
+
aiofiles==24.1.0
|
| 4 |
+
# via gradio
|
| 5 |
+
annotated-doc==0.0.4
|
| 6 |
+
# via fastapi
|
| 7 |
+
annotated-types==0.7.0
|
| 8 |
+
# via pydantic
|
| 9 |
+
anthropic==0.79.0
|
| 10 |
+
# via langchain-anthropic
|
| 11 |
+
anyio==4.12.1
|
| 12 |
+
# via
|
| 13 |
+
# anthropic
|
| 14 |
+
# google-genai
|
| 15 |
+
# gradio
|
| 16 |
+
# httpx
|
| 17 |
+
# openai
|
| 18 |
+
# starlette
|
| 19 |
+
audioop-lts==0.2.2 ; python_full_version >= '3.13'
|
| 20 |
+
# via gradio
|
| 21 |
+
brotli==1.2.0
|
| 22 |
+
# via gradio
|
| 23 |
+
certifi==2026.1.4
|
| 24 |
+
# via
|
| 25 |
+
# httpcore
|
| 26 |
+
# httpx
|
| 27 |
+
# requests
|
| 28 |
+
cffi==2.0.0 ; platform_python_implementation != 'PyPy'
|
| 29 |
+
# via cryptography
|
| 30 |
+
charset-normalizer==3.4.4
|
| 31 |
+
# via requests
|
| 32 |
+
click==8.3.1
|
| 33 |
+
# via
|
| 34 |
+
# typer
|
| 35 |
+
# uvicorn
|
| 36 |
+
colorama==0.4.6 ; sys_platform == 'win32'
|
| 37 |
+
# via
|
| 38 |
+
# click
|
| 39 |
+
# tqdm
|
| 40 |
+
contourpy==1.3.2 ; python_full_version < '3.11'
|
| 41 |
+
# via matplotlib
|
| 42 |
+
contourpy==1.3.3 ; python_full_version >= '3.11'
|
| 43 |
+
# via matplotlib
|
| 44 |
+
cryptography==46.0.5
|
| 45 |
+
# via google-auth
|
| 46 |
+
cycler==0.12.1
|
| 47 |
+
# via matplotlib
|
| 48 |
+
distro==1.9.0
|
| 49 |
+
# via
|
| 50 |
+
# anthropic
|
| 51 |
+
# google-genai
|
| 52 |
+
# openai
|
| 53 |
+
docstring-parser==0.17.0
|
| 54 |
+
# via anthropic
|
| 55 |
+
exceptiongroup==1.3.1 ; python_full_version < '3.11'
|
| 56 |
+
# via anyio
|
| 57 |
+
fastapi==0.128.6
|
| 58 |
+
# via gradio
|
| 59 |
+
ffmpy==1.0.0
|
| 60 |
+
# via gradio
|
| 61 |
+
filelock==3.20.3
|
| 62 |
+
# via huggingface-hub
|
| 63 |
+
filetype==1.2.0
|
| 64 |
+
# via langchain-google-genai
|
| 65 |
+
fonttools==4.61.1
|
| 66 |
+
# via matplotlib
|
| 67 |
+
fsspec==2026.2.0
|
| 68 |
+
# via
|
| 69 |
+
# gradio-client
|
| 70 |
+
# huggingface-hub
|
| 71 |
+
google-auth==2.48.0
|
| 72 |
+
# via google-genai
|
| 73 |
+
google-genai==1.63.0
|
| 74 |
+
# via langchain-google-genai
|
| 75 |
+
gradio==6.5.1
|
| 76 |
+
# via cashy
|
| 77 |
+
gradio-client==2.0.3
|
| 78 |
+
# via gradio
|
| 79 |
+
groovy==0.1.2
|
| 80 |
+
# via gradio
|
| 81 |
+
h11==0.16.0
|
| 82 |
+
# via
|
| 83 |
+
# httpcore
|
| 84 |
+
# uvicorn
|
| 85 |
+
hf-xet==1.2.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
|
| 86 |
+
# via huggingface-hub
|
| 87 |
+
httpcore==1.0.9
|
| 88 |
+
# via httpx
|
| 89 |
+
httpx==0.28.1
|
| 90 |
+
# via
|
| 91 |
+
# anthropic
|
| 92 |
+
# google-genai
|
| 93 |
+
# gradio
|
| 94 |
+
# gradio-client
|
| 95 |
+
# langgraph-sdk
|
| 96 |
+
# langsmith
|
| 97 |
+
# openai
|
| 98 |
+
# safehttpx
|
| 99 |
+
huggingface-hub==0.36.2
|
| 100 |
+
# via
|
| 101 |
+
# cashy
|
| 102 |
+
# gradio
|
| 103 |
+
# gradio-client
|
| 104 |
+
# langchain-huggingface
|
| 105 |
+
# tokenizers
|
| 106 |
+
idna==3.11
|
| 107 |
+
# via
|
| 108 |
+
# anyio
|
| 109 |
+
# httpx
|
| 110 |
+
# requests
|
| 111 |
+
jinja2==3.1.6
|
| 112 |
+
# via gradio
|
| 113 |
+
jiter==0.13.0
|
| 114 |
+
# via
|
| 115 |
+
# anthropic
|
| 116 |
+
# openai
|
| 117 |
+
jsonpatch==1.33
|
| 118 |
+
# via langchain-core
|
| 119 |
+
jsonpointer==3.0.0
|
| 120 |
+
# via jsonpatch
|
| 121 |
+
kiwisolver==1.4.9
|
| 122 |
+
# via matplotlib
|
| 123 |
+
langchain==1.2.9
|
| 124 |
+
# via cashy
|
| 125 |
+
langchain-anthropic==1.3.3
|
| 126 |
+
# via cashy
|
| 127 |
+
langchain-core==1.2.13
|
| 128 |
+
# via
|
| 129 |
+
# cashy
|
| 130 |
+
# langchain
|
| 131 |
+
# langchain-anthropic
|
| 132 |
+
# langchain-google-genai
|
| 133 |
+
# langchain-huggingface
|
| 134 |
+
# langchain-openai
|
| 135 |
+
# langgraph
|
| 136 |
+
# langgraph-checkpoint
|
| 137 |
+
# langgraph-prebuilt
|
| 138 |
+
langchain-google-genai==4.2.0
|
| 139 |
+
# via cashy
|
| 140 |
+
langchain-huggingface==1.2.0
|
| 141 |
+
# via cashy
|
| 142 |
+
langchain-openai==1.1.9
|
| 143 |
+
# via cashy
|
| 144 |
+
langgraph==1.0.8
|
| 145 |
+
# via
|
| 146 |
+
# cashy
|
| 147 |
+
# langchain
|
| 148 |
+
langgraph-checkpoint==4.0.0
|
| 149 |
+
# via
|
| 150 |
+
# langgraph
|
| 151 |
+
# langgraph-checkpoint-postgres
|
| 152 |
+
# langgraph-prebuilt
|
| 153 |
+
langgraph-checkpoint-postgres==3.0.4
|
| 154 |
+
# via cashy
|
| 155 |
+
langgraph-prebuilt==1.0.7
|
| 156 |
+
# via langgraph
|
| 157 |
+
langgraph-sdk==0.3.4
|
| 158 |
+
# via langgraph
|
| 159 |
+
langsmith==0.7.0
|
| 160 |
+
# via
|
| 161 |
+
# cashy
|
| 162 |
+
# langchain-core
|
| 163 |
+
markdown-it-py==4.0.0
|
| 164 |
+
# via rich
|
| 165 |
+
markupsafe==3.0.3
|
| 166 |
+
# via
|
| 167 |
+
# gradio
|
| 168 |
+
# jinja2
|
| 169 |
+
matplotlib==3.10.8
|
| 170 |
+
# via cashy
|
| 171 |
+
mdurl==0.1.2
|
| 172 |
+
# via markdown-it-py
|
| 173 |
+
numpy==2.2.6 ; python_full_version < '3.11'
|
| 174 |
+
# via
|
| 175 |
+
# contourpy
|
| 176 |
+
# gradio
|
| 177 |
+
# matplotlib
|
| 178 |
+
# pandas
|
| 179 |
+
numpy==2.4.2 ; python_full_version >= '3.11'
|
| 180 |
+
# via
|
| 181 |
+
# contourpy
|
| 182 |
+
# gradio
|
| 183 |
+
# matplotlib
|
| 184 |
+
# pandas
|
| 185 |
+
openai==2.21.0
|
| 186 |
+
# via langchain-openai
|
| 187 |
+
orjson==3.11.7
|
| 188 |
+
# via
|
| 189 |
+
# gradio
|
| 190 |
+
# langgraph-checkpoint-postgres
|
| 191 |
+
# langgraph-sdk
|
| 192 |
+
# langsmith
|
| 193 |
+
ormsgpack==1.12.2
|
| 194 |
+
# via langgraph-checkpoint
|
| 195 |
+
packaging==26.0
|
| 196 |
+
# via
|
| 197 |
+
# gradio
|
| 198 |
+
# gradio-client
|
| 199 |
+
# huggingface-hub
|
| 200 |
+
# langchain-core
|
| 201 |
+
# langsmith
|
| 202 |
+
# matplotlib
|
| 203 |
+
pandas==2.3.3
|
| 204 |
+
# via gradio
|
| 205 |
+
pillow==12.1.0
|
| 206 |
+
# via
|
| 207 |
+
# gradio
|
| 208 |
+
# matplotlib
|
| 209 |
+
psycopg==3.3.2
|
| 210 |
+
# via
|
| 211 |
+
# cashy
|
| 212 |
+
# langgraph-checkpoint-postgres
|
| 213 |
+
psycopg-binary==3.3.2 ; implementation_name != 'pypy'
|
| 214 |
+
# via psycopg
|
| 215 |
+
psycopg-pool==3.3.0
|
| 216 |
+
# via langgraph-checkpoint-postgres
|
| 217 |
+
psycopg2-binary==2.9.11
|
| 218 |
+
# via cashy
|
| 219 |
+
pyasn1==0.6.2
|
| 220 |
+
# via
|
| 221 |
+
# pyasn1-modules
|
| 222 |
+
# rsa
|
| 223 |
+
pyasn1-modules==0.4.2
|
| 224 |
+
# via google-auth
|
| 225 |
+
pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
| 226 |
+
# via cffi
|
| 227 |
+
pydantic==2.12.5
|
| 228 |
+
# via
|
| 229 |
+
# anthropic
|
| 230 |
+
# cashy
|
| 231 |
+
# fastapi
|
| 232 |
+
# google-genai
|
| 233 |
+
# gradio
|
| 234 |
+
# langchain
|
| 235 |
+
# langchain-anthropic
|
| 236 |
+
# langchain-core
|
| 237 |
+
# langchain-google-genai
|
| 238 |
+
# langgraph
|
| 239 |
+
# langsmith
|
| 240 |
+
# openai
|
| 241 |
+
# pydantic-settings
|
| 242 |
+
pydantic-core==2.41.5
|
| 243 |
+
# via pydantic
|
| 244 |
+
pydantic-settings==2.12.0
|
| 245 |
+
# via cashy
|
| 246 |
+
pydub==0.25.1
|
| 247 |
+
# via gradio
|
| 248 |
+
pygments==2.19.2
|
| 249 |
+
# via rich
|
| 250 |
+
pyparsing==3.3.2
|
| 251 |
+
# via matplotlib
|
| 252 |
+
python-dateutil==2.9.0.post0
|
| 253 |
+
# via
|
| 254 |
+
# matplotlib
|
| 255 |
+
# pandas
|
| 256 |
+
python-dotenv==1.2.1
|
| 257 |
+
# via
|
| 258 |
+
# cashy
|
| 259 |
+
# pydantic-settings
|
| 260 |
+
python-multipart==0.0.22
|
| 261 |
+
# via gradio
|
| 262 |
+
pytz==2025.2
|
| 263 |
+
# via
|
| 264 |
+
# gradio
|
| 265 |
+
# pandas
|
| 266 |
+
pyyaml==6.0.3
|
| 267 |
+
# via
|
| 268 |
+
# gradio
|
| 269 |
+
# huggingface-hub
|
| 270 |
+
# langchain-core
|
| 271 |
+
regex==2026.1.15
|
| 272 |
+
# via tiktoken
|
| 273 |
+
requests==2.32.5
|
| 274 |
+
# via
|
| 275 |
+
# google-auth
|
| 276 |
+
# google-genai
|
| 277 |
+
# huggingface-hub
|
| 278 |
+
# langsmith
|
| 279 |
+
# requests-toolbelt
|
| 280 |
+
# tiktoken
|
| 281 |
+
requests-toolbelt==1.0.0
|
| 282 |
+
# via langsmith
|
| 283 |
+
rich==14.3.2
|
| 284 |
+
# via typer
|
| 285 |
+
rsa==4.9.1
|
| 286 |
+
# via google-auth
|
| 287 |
+
safehttpx==0.1.7
|
| 288 |
+
# via gradio
|
| 289 |
+
semantic-version==2.10.0
|
| 290 |
+
# via gradio
|
| 291 |
+
shellingham==1.5.4
|
| 292 |
+
# via typer
|
| 293 |
+
six==1.17.0
|
| 294 |
+
# via python-dateutil
|
| 295 |
+
sniffio==1.3.1
|
| 296 |
+
# via
|
| 297 |
+
# anthropic
|
| 298 |
+
# google-genai
|
| 299 |
+
# openai
|
| 300 |
+
starlette==0.52.1
|
| 301 |
+
# via
|
| 302 |
+
# fastapi
|
| 303 |
+
# gradio
|
| 304 |
+
tenacity==9.1.4
|
| 305 |
+
# via
|
| 306 |
+
# google-genai
|
| 307 |
+
# langchain-core
|
| 308 |
+
tiktoken==0.12.0
|
| 309 |
+
# via langchain-openai
|
| 310 |
+
tokenizers==0.22.2
|
| 311 |
+
# via langchain-huggingface
|
| 312 |
+
tomlkit==0.13.3
|
| 313 |
+
# via gradio
|
| 314 |
+
tqdm==4.67.3
|
| 315 |
+
# via
|
| 316 |
+
# huggingface-hub
|
| 317 |
+
# openai
|
| 318 |
+
typer==0.21.1
|
| 319 |
+
# via gradio
|
| 320 |
+
typing-extensions==4.15.0
|
| 321 |
+
# via
|
| 322 |
+
# anthropic
|
| 323 |
+
# anyio
|
| 324 |
+
# cryptography
|
| 325 |
+
# exceptiongroup
|
| 326 |
+
# fastapi
|
| 327 |
+
# google-genai
|
| 328 |
+
# gradio
|
| 329 |
+
# gradio-client
|
| 330 |
+
# huggingface-hub
|
| 331 |
+
# langchain-core
|
| 332 |
+
# openai
|
| 333 |
+
# psycopg
|
| 334 |
+
# psycopg-pool
|
| 335 |
+
# pydantic
|
| 336 |
+
# pydantic-core
|
| 337 |
+
# starlette
|
| 338 |
+
# typer
|
| 339 |
+
# typing-inspection
|
| 340 |
+
# uvicorn
|
| 341 |
+
typing-inspection==0.4.2
|
| 342 |
+
# via
|
| 343 |
+
# fastapi
|
| 344 |
+
# pydantic
|
| 345 |
+
# pydantic-settings
|
| 346 |
+
tzdata==2025.3
|
| 347 |
+
# via
|
| 348 |
+
# pandas
|
| 349 |
+
# psycopg
|
| 350 |
+
urllib3==2.6.3
|
| 351 |
+
# via requests
|
| 352 |
+
uuid-utils==0.14.0
|
| 353 |
+
# via
|
| 354 |
+
# langchain-core
|
| 355 |
+
# langsmith
|
| 356 |
+
uvicorn==0.40.0
|
| 357 |
+
# via gradio
|
| 358 |
+
websockets==15.0.1
|
| 359 |
+
# via google-genai
|
| 360 |
+
xxhash==3.6.0
|
| 361 |
+
# via
|
| 362 |
+
# langgraph
|
| 363 |
+
# langsmith
|
| 364 |
+
zstandard==0.25.0
|
| 365 |
+
# via langsmith
|
scripts/evaluators.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Code-based evaluators for Cashy LangSmith experiments.
|
| 3 |
+
|
| 4 |
+
Each evaluator uses the new-style signature (outputs, reference_outputs)
|
| 5 |
+
supported in langsmith 0.7.0. They receive:
|
| 6 |
+
- outputs: dict returned by the target function (response, tools_called, tool_args, error)
|
| 7 |
+
- reference_outputs: dict from the dataset example's outputs field
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def tool_usage(outputs: dict, reference_outputs: dict) -> dict:
|
| 12 |
+
"""Check if at least one expected tool was called."""
|
| 13 |
+
expected = reference_outputs.get("expected_tools", [])
|
| 14 |
+
actual = outputs.get("tools_called", [])
|
| 15 |
+
|
| 16 |
+
if not expected:
|
| 17 |
+
score = 1
|
| 18 |
+
else:
|
| 19 |
+
score = 1 if any(t in actual for t in expected) else 0
|
| 20 |
+
|
| 21 |
+
return {"key": "tool_usage", "score": score}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def content_contains(outputs: dict, reference_outputs: dict) -> dict:
|
| 25 |
+
"""Check if all expected substrings appear in the response (case-insensitive)."""
|
| 26 |
+
expected = reference_outputs.get("expected_output_contains", [])
|
| 27 |
+
response = (outputs.get("response") or "").lower()
|
| 28 |
+
|
| 29 |
+
if not expected:
|
| 30 |
+
score = 1
|
| 31 |
+
else:
|
| 32 |
+
score = 1 if all(s.lower() in response for s in expected) else 0
|
| 33 |
+
|
| 34 |
+
return {"key": "content_contains", "score": score}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def tool_args_match(outputs: dict, reference_outputs: dict) -> dict:
|
| 38 |
+
"""Check if tool calls contain the expected arguments.
|
| 39 |
+
|
| 40 |
+
Compares each expected key-value pair against all actual tool call args.
|
| 41 |
+
Score = fraction of expected pairs that were found in any tool call.
|
| 42 |
+
"""
|
| 43 |
+
expected_args = reference_outputs.get("expected_tool_args", {})
|
| 44 |
+
actual_args_list = outputs.get("tool_args", [])
|
| 45 |
+
|
| 46 |
+
if not expected_args:
|
| 47 |
+
return {"key": "tool_args_match", "score": 1}
|
| 48 |
+
|
| 49 |
+
matched = 0
|
| 50 |
+
total = len(expected_args)
|
| 51 |
+
|
| 52 |
+
for key, expected_val in expected_args.items():
|
| 53 |
+
for actual_args in actual_args_list:
|
| 54 |
+
actual_val = actual_args.get(key)
|
| 55 |
+
if actual_val is not None and str(actual_val).lower() == str(expected_val).lower():
|
| 56 |
+
matched += 1
|
| 57 |
+
break
|
| 58 |
+
|
| 59 |
+
score = matched / total if total > 0 else 1
|
| 60 |
+
return {"key": "tool_args_match", "score": score}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def no_error(outputs: dict) -> dict:
|
| 64 |
+
"""Check that no error occurred during agent execution."""
|
| 65 |
+
error = outputs.get("error")
|
| 66 |
+
score = 1 if not error else 0
|
| 67 |
+
return {"key": "no_error", "score": score}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# List of all evaluators for easy import
|
| 71 |
+
all_evaluators = [tool_usage, content_contains, tool_args_match, no_error]
|
scripts/experiment_table_qa.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Experiment: Local Table QA with TAPAS/TAPEX models on financial data.
|
| 3 |
+
|
| 4 |
+
Runs a table QA model locally on CPU to answer questions about data
|
| 5 |
+
from the PostgreSQL financial database.
|
| 6 |
+
|
| 7 |
+
Supports two architectures:
|
| 8 |
+
- TAPAS (google/tapas-*): cell selection + aggregation
|
| 9 |
+
- TAPEX (microsoft/tapex-*): seq2seq text generation (BART-based)
|
| 10 |
+
|
| 11 |
+
Run:
|
| 12 |
+
uv run --extra experiment python scripts/experiment_table_qa.py
|
| 13 |
+
uv run --extra experiment python scripts/experiment_table_qa.py --model google/tapas-small-finetuned-wtq
|
| 14 |
+
uv run --extra experiment python scripts/experiment_table_qa.py --model microsoft/tapex-base-finetuned-wtq
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import argparse
|
| 18 |
+
import json
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
from datetime import datetime
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
# Add project root to path so we can import src.*
|
| 25 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 26 |
+
|
| 27 |
+
import pandas as pd
|
| 28 |
+
|
| 29 |
+
from src.db.connection import get_connection
|
| 30 |
+
|
| 31 |
+
DEFAULT_MODEL = "google/tapas-mini-finetuned-wtq"
|
| 32 |
+
|
| 33 |
+
QUERY = """
|
| 34 |
+
SELECT
|
| 35 |
+
transaction_date::text AS date,
|
| 36 |
+
transaction_description AS description,
|
| 37 |
+
category_name AS category,
|
| 38 |
+
entry_amount::text AS amount,
|
| 39 |
+
account_name AS account
|
| 40 |
+
FROM v_transaction_details
|
| 41 |
+
WHERE category_name IS NOT NULL
|
| 42 |
+
ORDER BY transaction_date DESC
|
| 43 |
+
LIMIT 15
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
QUESTIONS = [
|
| 47 |
+
"What is the total amount?",
|
| 48 |
+
"Which category has the highest amount?",
|
| 49 |
+
"How many transactions are there?",
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
RESULTS_FILE = Path(__file__).resolve().parent.parent / "eval_cases" / "table_qa_results.jsonl"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def is_tapex(model_name: str) -> bool:
|
| 56 |
+
return "tapex" in model_name.lower()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def fetch_table() -> pd.DataFrame:
|
| 60 |
+
print("[STEP 1] Connecting to PostgreSQL...")
|
| 61 |
+
with get_connection() as conn:
|
| 62 |
+
print(f" -> Connected to: {conn.dsn}")
|
| 63 |
+
print(f" -> Executing query:\n{QUERY.strip()}")
|
| 64 |
+
|
| 65 |
+
with conn.cursor() as cur:
|
| 66 |
+
cur.execute(QUERY)
|
| 67 |
+
columns = [desc[0] for desc in cur.description]
|
| 68 |
+
rows = cur.fetchall()
|
| 69 |
+
|
| 70 |
+
print(f" -> Raw result: {len(rows)} rows, {len(columns)} columns")
|
| 71 |
+
print(f" -> Columns: {columns}")
|
| 72 |
+
|
| 73 |
+
# Build DataFrame from dict of lists — pandas 2.x defaults to object dtype.
|
| 74 |
+
# TAPAS tokenizer mutates cells via iloc with its internal Cell namedtuple,
|
| 75 |
+
# which requires object dtype (incompatible with pandas 3.0 StringDtype).
|
| 76 |
+
data = {col: [str(row[i]) for row in rows] for i, col in enumerate(columns)}
|
| 77 |
+
df = pd.DataFrame.from_dict(data)
|
| 78 |
+
|
| 79 |
+
print(f" -> Dtypes (should be object):\n{df.dtypes.to_string()}")
|
| 80 |
+
print(f" -> Sample row: {dict(df.iloc[0])}")
|
| 81 |
+
print()
|
| 82 |
+
return df
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def load_tapas(model_name):
|
| 86 |
+
from transformers import pipeline
|
| 87 |
+
|
| 88 |
+
tqa = pipeline("table-question-answering", model=model_name, device=-1)
|
| 89 |
+
param_count = sum(p.numel() for p in tqa.model.parameters())
|
| 90 |
+
print(f" -> Architecture: TAPAS (cell selection + aggregation)")
|
| 91 |
+
print(f" -> Model class: {type(tqa.model).__name__}")
|
| 92 |
+
print(f" -> Tokenizer: {type(tqa.tokenizer).__name__}")
|
| 93 |
+
print(f" -> Model params: {param_count:,}")
|
| 94 |
+
return tqa, param_count
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def load_tapex(model_name):
|
| 98 |
+
from transformers import BartForConditionalGeneration, TapexTokenizer
|
| 99 |
+
|
| 100 |
+
tokenizer = TapexTokenizer.from_pretrained(model_name)
|
| 101 |
+
model = BartForConditionalGeneration.from_pretrained(model_name)
|
| 102 |
+
param_count = sum(p.numel() for p in model.parameters())
|
| 103 |
+
print(f" -> Architecture: TAPEX (seq2seq text generation, BART-based)")
|
| 104 |
+
print(f" -> Model class: {type(model).__name__}")
|
| 105 |
+
print(f" -> Tokenizer: {type(tokenizer).__name__}")
|
| 106 |
+
print(f" -> Model params: {param_count:,}")
|
| 107 |
+
return (tokenizer, model), param_count
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def run_tapas(tqa, table, query):
|
| 111 |
+
result = tqa(table=table, query=query)
|
| 112 |
+
return {
|
| 113 |
+
"answer": result["answer"],
|
| 114 |
+
"cells": result.get("cells", []),
|
| 115 |
+
"aggregator": result.get("aggregator", "NONE"),
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def run_tapex(tapex_pair, table, query):
|
| 120 |
+
tokenizer, model = tapex_pair
|
| 121 |
+
encoding = tokenizer(table=table, query=query, return_tensors="pt", truncation=True)
|
| 122 |
+
print(f" -> Input token count: {encoding['input_ids'].shape[1]}")
|
| 123 |
+
outputs = model.generate(**encoding, max_new_tokens=50)
|
| 124 |
+
decoded = tokenizer.batch_decode(outputs, skip_special_tokens=True)
|
| 125 |
+
answer = decoded[0] if decoded else ""
|
| 126 |
+
return {
|
| 127 |
+
"answer": answer,
|
| 128 |
+
"cells": [],
|
| 129 |
+
"aggregator": "seq2seq",
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def main():
|
| 134 |
+
parser = argparse.ArgumentParser(description="Table QA experiment (TAPAS/TAPEX)")
|
| 135 |
+
parser.add_argument("--model", default=DEFAULT_MODEL, help="HuggingFace model name")
|
| 136 |
+
args = parser.parse_args()
|
| 137 |
+
|
| 138 |
+
model_name = args.model
|
| 139 |
+
use_tapex = is_tapex(model_name)
|
| 140 |
+
run_results = {
|
| 141 |
+
"timestamp": datetime.now().isoformat(),
|
| 142 |
+
"model": model_name,
|
| 143 |
+
"architecture": "tapex" if use_tapex else "tapas",
|
| 144 |
+
"questions": [],
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
# --- Model loading ---
|
| 148 |
+
print("=" * 60)
|
| 149 |
+
print("[STEP 2] Loading model")
|
| 150 |
+
print("=" * 60)
|
| 151 |
+
print(f" -> Model: {model_name}")
|
| 152 |
+
print(f" -> Device: CPU")
|
| 153 |
+
print()
|
| 154 |
+
|
| 155 |
+
t0 = time.time()
|
| 156 |
+
if use_tapex:
|
| 157 |
+
model_obj, param_count = load_tapex(model_name)
|
| 158 |
+
else:
|
| 159 |
+
model_obj, param_count = load_tapas(model_name)
|
| 160 |
+
load_time = time.time() - t0
|
| 161 |
+
|
| 162 |
+
print(f" -> Load time: {load_time:.2f}s")
|
| 163 |
+
print()
|
| 164 |
+
|
| 165 |
+
run_results["params"] = param_count
|
| 166 |
+
run_results["load_time_s"] = round(load_time, 2)
|
| 167 |
+
|
| 168 |
+
# --- Data fetching ---
|
| 169 |
+
print("=" * 60)
|
| 170 |
+
print("[STEP 1] Fetching data from PostgreSQL")
|
| 171 |
+
print("=" * 60)
|
| 172 |
+
table = fetch_table()
|
| 173 |
+
|
| 174 |
+
# --- Display table ---
|
| 175 |
+
print("=" * 60)
|
| 176 |
+
print("TABLE (15 most recent transactions)")
|
| 177 |
+
print("=" * 60)
|
| 178 |
+
print(table.to_string(index=False))
|
| 179 |
+
print()
|
| 180 |
+
|
| 181 |
+
# --- Q&A ---
|
| 182 |
+
run_fn = run_tapex if use_tapex else run_tapas
|
| 183 |
+
|
| 184 |
+
print("=" * 60)
|
| 185 |
+
print("[STEP 3] Running Table QA inference")
|
| 186 |
+
print("=" * 60)
|
| 187 |
+
for i, q in enumerate(QUESTIONS, 1):
|
| 188 |
+
print(f"\n--- Question {i}/{len(QUESTIONS)} ---")
|
| 189 |
+
print(f" -> Input query: {q!r}")
|
| 190 |
+
print(f" -> Input table shape: {table.shape}")
|
| 191 |
+
|
| 192 |
+
t0 = time.time()
|
| 193 |
+
result = run_fn(model_obj, table, q)
|
| 194 |
+
inference_time = time.time() - t0
|
| 195 |
+
|
| 196 |
+
print(f" -> Answer: {result['answer']}")
|
| 197 |
+
print(f" -> Cells: {result.get('cells', [])}")
|
| 198 |
+
print(f" -> Aggregator: {result.get('aggregator', 'N/A')}")
|
| 199 |
+
print(f" -> Inference time: {inference_time:.3f}s")
|
| 200 |
+
|
| 201 |
+
run_results["questions"].append({
|
| 202 |
+
"query": q,
|
| 203 |
+
"answer": result["answer"],
|
| 204 |
+
"cells": result.get("cells", []),
|
| 205 |
+
"aggregator": result.get("aggregator", "N/A"),
|
| 206 |
+
"inference_time_s": round(inference_time, 3),
|
| 207 |
+
})
|
| 208 |
+
print()
|
| 209 |
+
|
| 210 |
+
# --- Save results ---
|
| 211 |
+
RESULTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 212 |
+
with open(RESULTS_FILE, "a") as f:
|
| 213 |
+
f.write(json.dumps(run_results, ensure_ascii=False) + "\n")
|
| 214 |
+
print(f"Results appended to: {RESULTS_FILE}")
|
| 215 |
+
|
| 216 |
+
print("=" * 60)
|
| 217 |
+
print("Experiment complete.")
|
| 218 |
+
print("=" * 60)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
if __name__ == "__main__":
|
| 222 |
+
main()
|
scripts/run_eval.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Run LangSmith evaluation experiments for the Cashy agent.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
uv run python scripts/run_eval.py # Run experiment (default dataset + prefix)
|
| 7 |
+
uv run python scripts/run_eval.py --prefix cashy-new-prompt # A/B test with custom prefix
|
| 8 |
+
uv run python scripts/run_eval.py --dataset cashy-eval-v2.0 # Use specific dataset
|
| 9 |
+
uv run python scripts/run_eval.py --upload # Upload eval cases to LangSmith
|
| 10 |
+
uv run python scripts/run_eval.py --upload --file eval_cases/eval_cases_v1.json # Upload specific file
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
import argparse
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
# Add project root to path
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 21 |
+
|
| 22 |
+
from dotenv import load_dotenv
|
| 23 |
+
load_dotenv(Path(__file__).parent.parent / ".env")
|
| 24 |
+
|
| 25 |
+
from langsmith.evaluation import evaluate
|
| 26 |
+
from langsmith import Client
|
| 27 |
+
from langchain_core.messages import HumanMessage
|
| 28 |
+
from src.agent.graph import create_agent
|
| 29 |
+
from scripts.evaluators import all_evaluators
|
| 30 |
+
|
| 31 |
+
logging.basicConfig(
|
| 32 |
+
level=logging.INFO,
|
| 33 |
+
format="%(asctime)s [%(name)-12s] %(levelname)-7s %(message)s",
|
| 34 |
+
datefmt="%H:%M:%S",
|
| 35 |
+
)
|
| 36 |
+
for name in ("httpx", "httpcore", "urllib3", "hf_transfer"):
|
| 37 |
+
logging.getLogger(name).setLevel(logging.WARNING)
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger("cashy.eval")
|
| 40 |
+
|
| 41 |
+
EVAL_FILE = Path(__file__).parent.parent / "eval_cases" / "eval_cases_v2.json"
|
| 42 |
+
DEFAULT_DATASET = "cashy-eval-v2.0"
|
| 43 |
+
DEFAULT_PREFIX = "cashy-baseline"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def make_target(agent):
|
| 47 |
+
"""Create a target function that wraps the agent for langsmith evaluate()."""
|
| 48 |
+
|
| 49 |
+
def run_agent(inputs: dict) -> dict:
|
| 50 |
+
try:
|
| 51 |
+
result = agent.invoke({"messages": [HumanMessage(content=inputs["input"])]})
|
| 52 |
+
response = result["messages"][-1].content
|
| 53 |
+
|
| 54 |
+
tools_called = []
|
| 55 |
+
tool_args = []
|
| 56 |
+
for msg in result["messages"]:
|
| 57 |
+
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
| 58 |
+
for tc in msg.tool_calls:
|
| 59 |
+
tools_called.append(tc["name"])
|
| 60 |
+
tool_args.append(tc.get("args", {}))
|
| 61 |
+
|
| 62 |
+
return {
|
| 63 |
+
"response": response,
|
| 64 |
+
"tools_called": tools_called,
|
| 65 |
+
"tool_args": tool_args,
|
| 66 |
+
"error": None,
|
| 67 |
+
}
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error("Agent error: %s", e)
|
| 70 |
+
return {
|
| 71 |
+
"response": None,
|
| 72 |
+
"tools_called": [],
|
| 73 |
+
"tool_args": [],
|
| 74 |
+
"error": str(e),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
return run_agent
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def upload_to_langsmith(eval_data: dict):
|
| 81 |
+
"""Upload eval cases as a LangSmith dataset with enriched outputs."""
|
| 82 |
+
client = Client()
|
| 83 |
+
version = eval_data["metadata"]["version"]
|
| 84 |
+
dataset_name = f"cashy-eval-v{version}"
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
dataset = client.create_dataset(
|
| 88 |
+
dataset_name=dataset_name,
|
| 89 |
+
description=eval_data["metadata"]["description"],
|
| 90 |
+
)
|
| 91 |
+
logger.info("Created dataset: %s", dataset_name)
|
| 92 |
+
except Exception:
|
| 93 |
+
dataset = client.read_dataset(dataset_name=dataset_name)
|
| 94 |
+
logger.info("Dataset already exists: %s", dataset_name)
|
| 95 |
+
|
| 96 |
+
for case in eval_data["cases"]:
|
| 97 |
+
client.create_example(
|
| 98 |
+
inputs={"input": case["input"]},
|
| 99 |
+
outputs={
|
| 100 |
+
"expected_tools": case.get("expected_tools", []),
|
| 101 |
+
"expected_output_contains": case.get("expected_output_contains", []),
|
| 102 |
+
"expected_tool_args": case.get("expected_tool_args", {}),
|
| 103 |
+
},
|
| 104 |
+
dataset_id=dataset.id,
|
| 105 |
+
metadata={
|
| 106 |
+
"category": case.get("category"),
|
| 107 |
+
"case_id": case["id"],
|
| 108 |
+
"criteria": case.get("evaluation_criteria", []),
|
| 109 |
+
},
|
| 110 |
+
)
|
| 111 |
+
logger.info("Uploaded %d examples to dataset '%s'", len(eval_data["cases"]), dataset_name)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def main():
|
| 115 |
+
parser = argparse.ArgumentParser(description="Run Cashy LangSmith evaluation")
|
| 116 |
+
parser.add_argument("--dataset", default=DEFAULT_DATASET, help="LangSmith dataset name")
|
| 117 |
+
parser.add_argument("--prefix", default=DEFAULT_PREFIX, help="Experiment prefix (for A/B naming)")
|
| 118 |
+
parser.add_argument("--upload", action="store_true", help="Upload eval cases to LangSmith dataset")
|
| 119 |
+
parser.add_argument("--file", default=str(EVAL_FILE), help="Local eval cases JSON file")
|
| 120 |
+
args = parser.parse_args()
|
| 121 |
+
|
| 122 |
+
if args.upload:
|
| 123 |
+
eval_data = json.loads(Path(args.file).read_text())
|
| 124 |
+
logger.info("Loaded %d eval cases from %s", len(eval_data["cases"]), args.file)
|
| 125 |
+
upload_to_langsmith(eval_data)
|
| 126 |
+
return
|
| 127 |
+
|
| 128 |
+
logger.info("Creating agent...")
|
| 129 |
+
agent = create_agent()
|
| 130 |
+
target = make_target(agent)
|
| 131 |
+
|
| 132 |
+
logger.info("Running experiment '%s' on dataset '%s'...", args.prefix, args.dataset)
|
| 133 |
+
results = evaluate(
|
| 134 |
+
target,
|
| 135 |
+
data=args.dataset,
|
| 136 |
+
evaluators=all_evaluators,
|
| 137 |
+
experiment_prefix=args.prefix,
|
| 138 |
+
max_concurrency=0,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
print("\n" + "=" * 60)
|
| 142 |
+
print(f"Experiment '{args.prefix}' complete.")
|
| 143 |
+
print(f"View results in LangSmith: Datasets > {args.dataset} > Experiments")
|
| 144 |
+
print("=" * 60)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
if __name__ == "__main__":
|
| 148 |
+
main()
|
scripts/sanitize_agent_export.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Sanitize Langflow agent exports by removing sensitive credentials.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python scripts/sanitize_agent_export.py <input_file> [output_file]
|
| 7 |
+
|
| 8 |
+
If output_file is not provided, it will create a sanitized version with '_sanitized' suffix.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any, Dict, List, Tuple
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# Patterns that indicate sensitive data
|
| 19 |
+
SENSITIVE_PATTERNS = [
|
| 20 |
+
r'sk-[a-zA-Z0-9]{20,}', # OpenAI API keys
|
| 21 |
+
r'sk-proj-[a-zA-Z0-9]{20,}', # OpenAI project API keys
|
| 22 |
+
r'postgresql://[^:]+:[^@]+@', # PostgreSQL connection strings with password
|
| 23 |
+
r'mongodb://[^:]+:[^@]+@', # MongoDB connection strings with password
|
| 24 |
+
r'Bearer\s+[a-zA-Z0-9\-._~+/]+=*', # Bearer tokens
|
| 25 |
+
r'[a-zA-Z0-9]{32,}', # Generic long alphanumeric strings (likely tokens)
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
# Keys that typically contain sensitive data
|
| 29 |
+
SENSITIVE_KEYS = [
|
| 30 |
+
'api_key',
|
| 31 |
+
'apikey',
|
| 32 |
+
'openai_api_key',
|
| 33 |
+
'langsmith_api_key',
|
| 34 |
+
'password',
|
| 35 |
+
'secret',
|
| 36 |
+
'secret_key',
|
| 37 |
+
'token',
|
| 38 |
+
'bearer',
|
| 39 |
+
'credential',
|
| 40 |
+
'auth',
|
| 41 |
+
'authorization',
|
| 42 |
+
'connection_string',
|
| 43 |
+
'database_url',
|
| 44 |
+
'db_password',
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
# Replacement values for different credential types
|
| 48 |
+
REPLACEMENTS = {
|
| 49 |
+
'api_key': '${OPENAI_API_KEY}',
|
| 50 |
+
'apikey': '${API_KEY}',
|
| 51 |
+
'openai_api_key': '${OPENAI_API_KEY}',
|
| 52 |
+
'langsmith_api_key': '${LANGSMITH_API_KEY}',
|
| 53 |
+
'password': '${DB_PASSWORD}',
|
| 54 |
+
'secret': '${SECRET_KEY}',
|
| 55 |
+
'secret_key': '${SECRET_KEY}',
|
| 56 |
+
'token': '${AUTH_TOKEN}',
|
| 57 |
+
'bearer': '${BEARER_TOKEN}',
|
| 58 |
+
'credential': '${CREDENTIAL}',
|
| 59 |
+
'auth': '${AUTH_KEY}',
|
| 60 |
+
'authorization': '${AUTHORIZATION}',
|
| 61 |
+
'connection_string': '${DATABASE_URL}',
|
| 62 |
+
'database_url': '${DATABASE_URL}',
|
| 63 |
+
'db_password': '${DB_PASSWORD}',
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class CredentialDetector:
|
| 68 |
+
"""Detect and report potential credentials in data structures."""
|
| 69 |
+
|
| 70 |
+
def __init__(self):
|
| 71 |
+
self.findings: List[Tuple[str, str, str]] = [] # (path, key, value)
|
| 72 |
+
|
| 73 |
+
def scan_value(self, value: str, path: str = "") -> bool:
|
| 74 |
+
"""Check if a value matches sensitive patterns."""
|
| 75 |
+
if not isinstance(value, str) or len(value) < 8:
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
for pattern in SENSITIVE_PATTERNS:
|
| 79 |
+
if re.search(pattern, value, re.IGNORECASE):
|
| 80 |
+
return True
|
| 81 |
+
return False
|
| 82 |
+
|
| 83 |
+
def scan_dict(self, data: Dict[str, Any], path: str = "") -> None:
|
| 84 |
+
"""Recursively scan dictionary for sensitive data."""
|
| 85 |
+
for key, value in data.items():
|
| 86 |
+
current_path = f"{path}.{key}" if path else key
|
| 87 |
+
|
| 88 |
+
# Check if key name suggests sensitive data
|
| 89 |
+
if any(sensitive in key.lower() for sensitive in SENSITIVE_KEYS):
|
| 90 |
+
if isinstance(value, str) and value:
|
| 91 |
+
self.findings.append((current_path, key, value))
|
| 92 |
+
|
| 93 |
+
# Check if value matches sensitive patterns
|
| 94 |
+
elif isinstance(value, str) and self.scan_value(value, current_path):
|
| 95 |
+
self.findings.append((current_path, key, value))
|
| 96 |
+
|
| 97 |
+
# Recurse into nested structures
|
| 98 |
+
elif isinstance(value, dict):
|
| 99 |
+
self.scan_dict(value, current_path)
|
| 100 |
+
elif isinstance(value, list):
|
| 101 |
+
self.scan_list(value, current_path)
|
| 102 |
+
|
| 103 |
+
def scan_list(self, data: List[Any], path: str = "") -> None:
|
| 104 |
+
"""Recursively scan list for sensitive data."""
|
| 105 |
+
for i, item in enumerate(data):
|
| 106 |
+
current_path = f"{path}[{i}]"
|
| 107 |
+
|
| 108 |
+
if isinstance(item, dict):
|
| 109 |
+
self.scan_dict(item, current_path)
|
| 110 |
+
elif isinstance(item, list):
|
| 111 |
+
self.scan_list(item, current_path)
|
| 112 |
+
elif isinstance(item, str) and self.scan_value(item, current_path):
|
| 113 |
+
self.findings.append((current_path, f"item_{i}", item))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def sanitize_value(key: str, value: str) -> str:
|
| 117 |
+
"""Replace sensitive value with appropriate placeholder."""
|
| 118 |
+
key_lower = key.lower()
|
| 119 |
+
|
| 120 |
+
# Use specific replacement if key matches known pattern
|
| 121 |
+
for sensitive_key, replacement in REPLACEMENTS.items():
|
| 122 |
+
if sensitive_key in key_lower:
|
| 123 |
+
return replacement
|
| 124 |
+
|
| 125 |
+
# Default replacement for unknown sensitive data
|
| 126 |
+
return "${CREDENTIAL}"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def sanitize_dict(data: Dict[str, Any]) -> Dict[str, Any]:
|
| 130 |
+
"""Recursively sanitize dictionary by replacing sensitive values."""
|
| 131 |
+
sanitized = {}
|
| 132 |
+
|
| 133 |
+
for key, value in data.items():
|
| 134 |
+
# Check if key suggests sensitive data
|
| 135 |
+
if any(sensitive in key.lower() for sensitive in SENSITIVE_KEYS):
|
| 136 |
+
if isinstance(value, str) and value:
|
| 137 |
+
sanitized[key] = sanitize_value(key, value)
|
| 138 |
+
else:
|
| 139 |
+
sanitized[key] = value
|
| 140 |
+
|
| 141 |
+
# Recurse into nested structures
|
| 142 |
+
elif isinstance(value, dict):
|
| 143 |
+
sanitized[key] = sanitize_dict(value)
|
| 144 |
+
elif isinstance(value, list):
|
| 145 |
+
sanitized[key] = sanitize_list(value)
|
| 146 |
+
else:
|
| 147 |
+
sanitized[key] = value
|
| 148 |
+
|
| 149 |
+
return sanitized
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def sanitize_list(data: List[Any]) -> List[Any]:
|
| 153 |
+
"""Recursively sanitize list by replacing sensitive values."""
|
| 154 |
+
sanitized = []
|
| 155 |
+
|
| 156 |
+
for item in data:
|
| 157 |
+
if isinstance(item, dict):
|
| 158 |
+
sanitized.append(sanitize_dict(item))
|
| 159 |
+
elif isinstance(item, list):
|
| 160 |
+
sanitized.append(sanitize_list(item))
|
| 161 |
+
else:
|
| 162 |
+
sanitized.append(item)
|
| 163 |
+
|
| 164 |
+
return sanitized
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def sanitize_agent_export(input_file: Path, output_file: Path = None) -> bool:
|
| 168 |
+
"""
|
| 169 |
+
Sanitize Langflow agent export by removing credentials.
|
| 170 |
+
|
| 171 |
+
Returns True if credentials were found and sanitized, False otherwise.
|
| 172 |
+
"""
|
| 173 |
+
# Read input file
|
| 174 |
+
try:
|
| 175 |
+
with open(input_file, 'r') as f:
|
| 176 |
+
data = json.load(f)
|
| 177 |
+
except Exception as e:
|
| 178 |
+
print(f"❌ Error reading {input_file}: {e}")
|
| 179 |
+
return False
|
| 180 |
+
|
| 181 |
+
# Scan for credentials
|
| 182 |
+
detector = CredentialDetector()
|
| 183 |
+
detector.scan_dict(data)
|
| 184 |
+
|
| 185 |
+
if not detector.findings:
|
| 186 |
+
print(f"✅ No credentials detected in {input_file}")
|
| 187 |
+
return False
|
| 188 |
+
|
| 189 |
+
# Report findings
|
| 190 |
+
print(f"⚠️ Found {len(detector.findings)} potential credential(s) in {input_file}:")
|
| 191 |
+
for path, key, value in detector.findings:
|
| 192 |
+
# Mask the value for display
|
| 193 |
+
masked = value[:8] + "..." if len(value) > 8 else "***"
|
| 194 |
+
print(f" - {path}: {key} = {masked}")
|
| 195 |
+
|
| 196 |
+
# Sanitize data
|
| 197 |
+
sanitized_data = sanitize_dict(data)
|
| 198 |
+
|
| 199 |
+
# Determine output file
|
| 200 |
+
if output_file is None:
|
| 201 |
+
output_file = input_file.parent / f"{input_file.stem}_sanitized{input_file.suffix}"
|
| 202 |
+
|
| 203 |
+
# Write sanitized output
|
| 204 |
+
try:
|
| 205 |
+
with open(output_file, 'w') as f:
|
| 206 |
+
json.dump(sanitized_data, f, indent=2)
|
| 207 |
+
print(f"✅ Sanitized version saved to: {output_file}")
|
| 208 |
+
return True
|
| 209 |
+
except Exception as e:
|
| 210 |
+
print(f"❌ Error writing {output_file}: {e}")
|
| 211 |
+
return False
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def main():
|
| 215 |
+
if len(sys.argv) < 2:
|
| 216 |
+
print("Usage: python sanitize_agent_export.py <input_file> [output_file]")
|
| 217 |
+
sys.exit(1)
|
| 218 |
+
|
| 219 |
+
input_file = Path(sys.argv[1])
|
| 220 |
+
output_file = Path(sys.argv[2]) if len(sys.argv) > 2 else None
|
| 221 |
+
|
| 222 |
+
if not input_file.exists():
|
| 223 |
+
print(f"❌ Error: {input_file} does not exist")
|
| 224 |
+
sys.exit(1)
|
| 225 |
+
|
| 226 |
+
# Run sanitization
|
| 227 |
+
found_credentials = sanitize_agent_export(input_file, output_file)
|
| 228 |
+
|
| 229 |
+
if found_credentials:
|
| 230 |
+
print("\n⚠️ WARNING: Credentials were found and replaced with placeholders.")
|
| 231 |
+
print(" Review the sanitized file before committing to Git.")
|
| 232 |
+
print(" Make sure to use environment variables in Langflow for all credentials.")
|
| 233 |
+
sys.exit(1) # Exit with error code to prevent accidental commits
|
| 234 |
+
else:
|
| 235 |
+
print("\n✅ File is safe to commit.")
|
| 236 |
+
sys.exit(0)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
if __name__ == "__main__":
|
| 240 |
+
main()
|
scripts/seed_demo_db.sql
ADDED
|
@@ -0,0 +1,1091 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- =============================================================================
|
| 2 |
+
-- Cashy Demo Database Seed Script
|
| 3 |
+
-- US Freelancer Persona — English Language
|
| 4 |
+
--
|
| 5 |
+
-- Usage:
|
| 6 |
+
-- createdb -U postgres cashy_demo
|
| 7 |
+
-- psql -U postgres -d cashy_demo -f scripts/seed_demo_db.sql
|
| 8 |
+
--
|
| 9 |
+
-- To reset: dropdb -U postgres cashy_demo && createdb -U postgres cashy_demo && psql -U postgres -d cashy_demo -f scripts/seed_demo_db.sql
|
| 10 |
+
-- =============================================================================
|
| 11 |
+
|
| 12 |
+
-- Grant privileges to the application user
|
| 13 |
+
GRANT ALL PRIVILEGES ON DATABASE cashy_demo TO financial_advisor;
|
| 14 |
+
|
| 15 |
+
-- =============================================================================
|
| 16 |
+
-- 1. TRIGGER FUNCTION
|
| 17 |
+
-- =============================================================================
|
| 18 |
+
|
| 19 |
+
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
| 20 |
+
RETURNS TRIGGER AS $$
|
| 21 |
+
BEGIN
|
| 22 |
+
NEW.updated_at = CURRENT_TIMESTAMP;
|
| 23 |
+
RETURN NEW;
|
| 24 |
+
END;
|
| 25 |
+
$$ LANGUAGE plpgsql;
|
| 26 |
+
|
| 27 |
+
-- =============================================================================
|
| 28 |
+
-- 2. TABLES (11)
|
| 29 |
+
-- =============================================================================
|
| 30 |
+
|
| 31 |
+
-- account_types
|
| 32 |
+
CREATE TABLE account_types (
|
| 33 |
+
id SERIAL PRIMARY KEY,
|
| 34 |
+
name VARCHAR(100) NOT NULL,
|
| 35 |
+
description TEXT,
|
| 36 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 37 |
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
| 38 |
+
);
|
| 39 |
+
CREATE TRIGGER trigger_account_types_updated_at
|
| 40 |
+
BEFORE UPDATE ON account_types FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
| 41 |
+
|
| 42 |
+
-- accounts
|
| 43 |
+
CREATE TABLE accounts (
|
| 44 |
+
id SERIAL PRIMARY KEY,
|
| 45 |
+
name VARCHAR(200) NOT NULL,
|
| 46 |
+
account_type_id INTEGER NOT NULL REFERENCES account_types(id),
|
| 47 |
+
current_balance NUMERIC(15,2) NOT NULL DEFAULT 0.00,
|
| 48 |
+
is_active BOOLEAN NOT NULL DEFAULT true,
|
| 49 |
+
account_number VARCHAR(50),
|
| 50 |
+
institution VARCHAR(100),
|
| 51 |
+
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
|
| 52 |
+
notes TEXT,
|
| 53 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 54 |
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 55 |
+
credit_limit NUMERIC(15,2) DEFAULT NULL,
|
| 56 |
+
opening_balance NUMERIC(15,2) NOT NULL DEFAULT 0.00,
|
| 57 |
+
CONSTRAINT unique_account_name UNIQUE (name)
|
| 58 |
+
);
|
| 59 |
+
CREATE INDEX idx_accounts_name ON accounts(name);
|
| 60 |
+
CREATE INDEX idx_accounts_type ON accounts(account_type_id);
|
| 61 |
+
CREATE INDEX idx_accounts_active ON accounts(is_active);
|
| 62 |
+
CREATE TRIGGER trigger_accounts_updated_at
|
| 63 |
+
BEFORE UPDATE ON accounts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
| 64 |
+
|
| 65 |
+
-- categories
|
| 66 |
+
CREATE TABLE categories (
|
| 67 |
+
id SERIAL PRIMARY KEY,
|
| 68 |
+
name VARCHAR(200) NOT NULL,
|
| 69 |
+
parent_category_id INTEGER REFERENCES categories(id),
|
| 70 |
+
category_type VARCHAR(20) NOT NULL,
|
| 71 |
+
description TEXT,
|
| 72 |
+
is_active BOOLEAN NOT NULL DEFAULT true,
|
| 73 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 74 |
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
| 75 |
+
);
|
| 76 |
+
CREATE INDEX idx_categories_type ON categories(category_type);
|
| 77 |
+
CREATE INDEX idx_categories_parent ON categories(parent_category_id);
|
| 78 |
+
CREATE TRIGGER trigger_categories_updated_at
|
| 79 |
+
BEFORE UPDATE ON categories FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
| 80 |
+
|
| 81 |
+
-- transactions
|
| 82 |
+
CREATE TABLE transactions (
|
| 83 |
+
id SERIAL PRIMARY KEY,
|
| 84 |
+
transaction_date DATE NOT NULL,
|
| 85 |
+
description VARCHAR(500) NOT NULL,
|
| 86 |
+
transaction_type VARCHAR(20) NOT NULL,
|
| 87 |
+
total_amount NUMERIC(15,2) NOT NULL,
|
| 88 |
+
reference_number VARCHAR(100),
|
| 89 |
+
notes TEXT,
|
| 90 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 91 |
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 92 |
+
CONSTRAINT transactions_transaction_type_check
|
| 93 |
+
CHECK (transaction_type IN ('income', 'expense', 'transfer', 'adjustment')),
|
| 94 |
+
CONSTRAINT adjustment_requires_notes
|
| 95 |
+
CHECK (transaction_type <> 'adjustment' OR notes IS NOT NULL)
|
| 96 |
+
);
|
| 97 |
+
CREATE INDEX idx_transactions_date ON transactions(transaction_date);
|
| 98 |
+
CREATE INDEX idx_transactions_type ON transactions(transaction_type);
|
| 99 |
+
CREATE INDEX idx_transactions_date_type ON transactions(transaction_date, transaction_type);
|
| 100 |
+
CREATE TRIGGER trigger_transactions_updated_at
|
| 101 |
+
BEFORE UPDATE ON transactions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
| 102 |
+
|
| 103 |
+
-- transaction_entries
|
| 104 |
+
CREATE TABLE transaction_entries (
|
| 105 |
+
id SERIAL PRIMARY KEY,
|
| 106 |
+
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
| 107 |
+
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
| 108 |
+
category_id INTEGER REFERENCES categories(id),
|
| 109 |
+
amount NUMERIC(15,2) NOT NULL,
|
| 110 |
+
entry_type VARCHAR(10) NOT NULL,
|
| 111 |
+
description VARCHAR(500),
|
| 112 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
| 113 |
+
);
|
| 114 |
+
CREATE INDEX idx_entries_transaction ON transaction_entries(transaction_id);
|
| 115 |
+
CREATE INDEX idx_entries_account ON transaction_entries(account_id);
|
| 116 |
+
CREATE INDEX idx_entries_category ON transaction_entries(category_id);
|
| 117 |
+
|
| 118 |
+
-- budgets
|
| 119 |
+
CREATE TABLE budgets (
|
| 120 |
+
id SERIAL PRIMARY KEY,
|
| 121 |
+
category_id INTEGER NOT NULL REFERENCES categories(id),
|
| 122 |
+
month_year DATE NOT NULL,
|
| 123 |
+
budgeted_amount NUMERIC(15,2) NOT NULL,
|
| 124 |
+
actual_amount NUMERIC(15,2) DEFAULT 0.00,
|
| 125 |
+
variance_amount NUMERIC(15,2) GENERATED ALWAYS AS (actual_amount - budgeted_amount) STORED,
|
| 126 |
+
variance_percentage NUMERIC(5,2) GENERATED ALWAYS AS (
|
| 127 |
+
CASE WHEN budgeted_amount = 0 THEN 0
|
| 128 |
+
ELSE ROUND((actual_amount - budgeted_amount) / budgeted_amount * 100, 2)
|
| 129 |
+
END
|
| 130 |
+
) STORED,
|
| 131 |
+
notes TEXT,
|
| 132 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 133 |
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 134 |
+
CONSTRAINT unique_category_month UNIQUE (category_id, month_year)
|
| 135 |
+
);
|
| 136 |
+
CREATE INDEX idx_budgets_month ON budgets(month_year);
|
| 137 |
+
CREATE INDEX idx_budgets_category ON budgets(category_id);
|
| 138 |
+
CREATE INDEX idx_budgets_category_month ON budgets(category_id, month_year);
|
| 139 |
+
CREATE TRIGGER trigger_budgets_updated_at
|
| 140 |
+
BEFORE UPDATE ON budgets FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
| 141 |
+
|
| 142 |
+
-- budget_goals
|
| 143 |
+
CREATE TABLE budget_goals (
|
| 144 |
+
id SERIAL PRIMARY KEY,
|
| 145 |
+
name VARCHAR(200) NOT NULL,
|
| 146 |
+
description TEXT,
|
| 147 |
+
target_amount NUMERIC(15,2) NOT NULL,
|
| 148 |
+
current_amount NUMERIC(15,2) NOT NULL DEFAULT 0.00,
|
| 149 |
+
target_date DATE,
|
| 150 |
+
goal_type VARCHAR(20) NOT NULL,
|
| 151 |
+
priority INTEGER,
|
| 152 |
+
is_active BOOLEAN NOT NULL DEFAULT true,
|
| 153 |
+
completion_percentage NUMERIC(5,2) GENERATED ALWAYS AS (
|
| 154 |
+
CASE WHEN target_amount = 0 THEN 0
|
| 155 |
+
ELSE ROUND(current_amount / target_amount * 100, 2)
|
| 156 |
+
END
|
| 157 |
+
) STORED,
|
| 158 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 159 |
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 160 |
+
CONSTRAINT budget_goals_goal_type_check
|
| 161 |
+
CHECK (goal_type IN ('emergency_fund', 'savings', 'debt_payoff', 'investment', 'purchase', 'other')),
|
| 162 |
+
CONSTRAINT budget_goals_priority_check CHECK (priority >= 1 AND priority <= 10)
|
| 163 |
+
);
|
| 164 |
+
CREATE INDEX idx_budget_goals_active ON budget_goals(is_active);
|
| 165 |
+
CREATE INDEX idx_budget_goals_type ON budget_goals(goal_type);
|
| 166 |
+
CREATE INDEX idx_budget_goals_target_date ON budget_goals(target_date);
|
| 167 |
+
CREATE TRIGGER trigger_budget_goals_updated_at
|
| 168 |
+
BEFORE UPDATE ON budget_goals FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
| 169 |
+
|
| 170 |
+
-- goal_contributions
|
| 171 |
+
CREATE TABLE goal_contributions (
|
| 172 |
+
id SERIAL PRIMARY KEY,
|
| 173 |
+
goal_id INTEGER NOT NULL REFERENCES budget_goals(id) ON DELETE CASCADE,
|
| 174 |
+
month_year DATE NOT NULL,
|
| 175 |
+
planned_amount NUMERIC(15,2) NOT NULL DEFAULT 0.00,
|
| 176 |
+
actual_amount NUMERIC(15,2) NOT NULL DEFAULT 0.00,
|
| 177 |
+
source_account_id INTEGER REFERENCES accounts(id),
|
| 178 |
+
notes TEXT,
|
| 179 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 180 |
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 181 |
+
CONSTRAINT goal_contributions_unique_goal_month UNIQUE (goal_id, month_year),
|
| 182 |
+
CONSTRAINT goal_contributions_planned_amount_check CHECK (planned_amount >= 0),
|
| 183 |
+
CONSTRAINT goal_contributions_actual_amount_check CHECK (actual_amount >= 0)
|
| 184 |
+
);
|
| 185 |
+
CREATE INDEX idx_goal_contributions_goal ON goal_contributions(goal_id);
|
| 186 |
+
CREATE INDEX idx_goal_contributions_month ON goal_contributions(month_year);
|
| 187 |
+
CREATE INDEX idx_goal_contributions_goal_month ON goal_contributions(goal_id, month_year);
|
| 188 |
+
CREATE INDEX idx_goal_contributions_source_account ON goal_contributions(source_account_id);
|
| 189 |
+
CREATE TRIGGER trigger_goal_contributions_updated_at
|
| 190 |
+
BEFORE UPDATE ON goal_contributions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
| 191 |
+
|
| 192 |
+
-- monthly_balances
|
| 193 |
+
CREATE TABLE monthly_balances (
|
| 194 |
+
id SERIAL PRIMARY KEY,
|
| 195 |
+
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
| 196 |
+
month_year DATE NOT NULL,
|
| 197 |
+
opening_balance NUMERIC(15,2) NOT NULL,
|
| 198 |
+
closing_balance NUMERIC(15,2) NOT NULL,
|
| 199 |
+
net_change NUMERIC(15,2) GENERATED ALWAYS AS (closing_balance - opening_balance) STORED,
|
| 200 |
+
total_deposits NUMERIC(15,2) DEFAULT 0.00,
|
| 201 |
+
total_withdrawals NUMERIC(15,2) DEFAULT 0.00,
|
| 202 |
+
average_daily_balance NUMERIC(15,2),
|
| 203 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 204 |
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 205 |
+
CONSTRAINT unique_account_month UNIQUE (account_id, month_year)
|
| 206 |
+
);
|
| 207 |
+
CREATE INDEX idx_monthly_balances_account ON monthly_balances(account_id);
|
| 208 |
+
CREATE INDEX idx_monthly_balances_month ON monthly_balances(month_year);
|
| 209 |
+
|
| 210 |
+
-- spending_patterns
|
| 211 |
+
CREATE TABLE spending_patterns (
|
| 212 |
+
id SERIAL PRIMARY KEY,
|
| 213 |
+
category_id INTEGER REFERENCES categories(id),
|
| 214 |
+
account_id INTEGER REFERENCES accounts(id),
|
| 215 |
+
month_year DATE NOT NULL,
|
| 216 |
+
pattern_type VARCHAR(50) NOT NULL,
|
| 217 |
+
average_amount NUMERIC(15,2),
|
| 218 |
+
frequency_count INTEGER,
|
| 219 |
+
confidence_score NUMERIC(3,2),
|
| 220 |
+
pattern_data JSONB,
|
| 221 |
+
insights TEXT,
|
| 222 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 223 |
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 224 |
+
CONSTRAINT spending_patterns_pattern_type_check
|
| 225 |
+
CHECK (pattern_type IN ('weekly', 'monthly', 'seasonal', 'trending_up', 'trending_down', 'volatile', 'stable')),
|
| 226 |
+
CONSTRAINT spending_patterns_confidence_score_check CHECK (confidence_score >= 0.00 AND confidence_score <= 1.00)
|
| 227 |
+
);
|
| 228 |
+
CREATE INDEX idx_spending_patterns_category ON spending_patterns(category_id);
|
| 229 |
+
CREATE INDEX idx_spending_patterns_month ON spending_patterns(month_year);
|
| 230 |
+
CREATE INDEX idx_spending_patterns_type ON spending_patterns(pattern_type);
|
| 231 |
+
|
| 232 |
+
-- financial_rules
|
| 233 |
+
CREATE TABLE financial_rules (
|
| 234 |
+
id SERIAL PRIMARY KEY,
|
| 235 |
+
rule_name VARCHAR(200) NOT NULL,
|
| 236 |
+
rule_type VARCHAR(50) NOT NULL,
|
| 237 |
+
parameters JSONB NOT NULL,
|
| 238 |
+
conditions JSONB,
|
| 239 |
+
is_active BOOLEAN NOT NULL DEFAULT true,
|
| 240 |
+
priority INTEGER DEFAULT 5,
|
| 241 |
+
description TEXT,
|
| 242 |
+
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 243 |
+
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
| 244 |
+
CONSTRAINT financial_rules_rule_type_check
|
| 245 |
+
CHECK (rule_type IN ('budget_alert', 'spending_limit', 'savings_target', 'investment_allocation', 'debt_warning', 'cash_flow')),
|
| 246 |
+
CONSTRAINT financial_rules_priority_check CHECK (priority >= 1 AND priority <= 10)
|
| 247 |
+
);
|
| 248 |
+
CREATE INDEX idx_financial_rules_active ON financial_rules(is_active);
|
| 249 |
+
CREATE INDEX idx_financial_rules_type ON financial_rules(rule_type);
|
| 250 |
+
|
| 251 |
+
-- =============================================================================
|
| 252 |
+
-- 3. VIEWS (8)
|
| 253 |
+
-- =============================================================================
|
| 254 |
+
|
| 255 |
+
CREATE VIEW v_transaction_details AS
|
| 256 |
+
SELECT
|
| 257 |
+
t.id AS transaction_id,
|
| 258 |
+
t.transaction_date,
|
| 259 |
+
t.description AS transaction_description,
|
| 260 |
+
t.transaction_type,
|
| 261 |
+
t.total_amount,
|
| 262 |
+
te.id AS entry_id,
|
| 263 |
+
a.name AS account_name,
|
| 264 |
+
a.institution,
|
| 265 |
+
c.name AS category_name,
|
| 266 |
+
pc.name AS parent_category_name,
|
| 267 |
+
te.amount AS entry_amount,
|
| 268 |
+
te.entry_type,
|
| 269 |
+
te.description AS entry_description,
|
| 270 |
+
t.notes,
|
| 271 |
+
t.created_at
|
| 272 |
+
FROM transactions t
|
| 273 |
+
JOIN transaction_entries te ON t.id = te.transaction_id
|
| 274 |
+
JOIN accounts a ON te.account_id = a.id
|
| 275 |
+
LEFT JOIN categories c ON te.category_id = c.id
|
| 276 |
+
LEFT JOIN categories pc ON c.parent_category_id = pc.id
|
| 277 |
+
ORDER BY t.transaction_date DESC, t.id DESC, te.id;
|
| 278 |
+
|
| 279 |
+
CREATE VIEW v_monthly_spending AS
|
| 280 |
+
SELECT
|
| 281 |
+
date_trunc('month', t.transaction_date::timestamp with time zone) AS month_year,
|
| 282 |
+
c.name AS category,
|
| 283 |
+
c.category_type,
|
| 284 |
+
SUM(te.amount) AS total_amount,
|
| 285 |
+
COUNT(*) AS transaction_count,
|
| 286 |
+
AVG(te.amount) AS average_amount
|
| 287 |
+
FROM transactions t
|
| 288 |
+
JOIN transaction_entries te ON t.id = te.transaction_id
|
| 289 |
+
LEFT JOIN categories c ON te.category_id = c.id
|
| 290 |
+
WHERE t.transaction_type = 'expense'
|
| 291 |
+
GROUP BY date_trunc('month', t.transaction_date::timestamp with time zone), c.name, c.category_type
|
| 292 |
+
ORDER BY date_trunc('month', t.transaction_date::timestamp with time zone) DESC, SUM(te.amount) DESC;
|
| 293 |
+
|
| 294 |
+
CREATE VIEW v_account_summary AS
|
| 295 |
+
SELECT
|
| 296 |
+
a.id,
|
| 297 |
+
a.name,
|
| 298 |
+
at.name AS account_type,
|
| 299 |
+
a.current_balance,
|
| 300 |
+
a.currency,
|
| 301 |
+
a.is_active,
|
| 302 |
+
COALESCE(mb.closing_balance, a.current_balance) AS last_month_balance,
|
| 303 |
+
a.current_balance - COALESCE(mb.closing_balance, a.current_balance) AS monthly_change
|
| 304 |
+
FROM accounts a
|
| 305 |
+
LEFT JOIN account_types at ON a.account_type_id = at.id
|
| 306 |
+
LEFT JOIN monthly_balances mb ON a.id = mb.account_id
|
| 307 |
+
AND mb.month_year = date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
|
| 308 |
+
WHERE a.is_active = true;
|
| 309 |
+
|
| 310 |
+
CREATE VIEW v_category_hierarchy AS
|
| 311 |
+
WITH RECURSIVE category_path AS (
|
| 312 |
+
SELECT id, name, parent_category_id, category_type,
|
| 313 |
+
name::text AS full_path, 1 AS level
|
| 314 |
+
FROM categories
|
| 315 |
+
WHERE parent_category_id IS NULL
|
| 316 |
+
UNION ALL
|
| 317 |
+
SELECT c.id, c.name, c.parent_category_id, c.category_type,
|
| 318 |
+
(cp.full_path || ' > ' || c.name::text) AS full_path,
|
| 319 |
+
cp.level + 1
|
| 320 |
+
FROM categories c
|
| 321 |
+
JOIN category_path cp ON c.parent_category_id = cp.id
|
| 322 |
+
)
|
| 323 |
+
SELECT id, name, parent_category_id, category_type, full_path, level
|
| 324 |
+
FROM category_path;
|
| 325 |
+
|
| 326 |
+
CREATE VIEW v_credit_utilization AS
|
| 327 |
+
SELECT
|
| 328 |
+
id, name, current_balance, credit_limit,
|
| 329 |
+
ABS(current_balance) AS debt_owed,
|
| 330 |
+
credit_limit - ABS(current_balance) AS available_credit,
|
| 331 |
+
CASE WHEN credit_limit > 0
|
| 332 |
+
THEN ROUND(ABS(current_balance) / credit_limit * 100, 2)
|
| 333 |
+
ELSE 0
|
| 334 |
+
END AS utilization_percentage
|
| 335 |
+
FROM accounts a
|
| 336 |
+
WHERE account_type_id = (SELECT id FROM account_types WHERE name = 'Credit Card')
|
| 337 |
+
AND is_active = true;
|
| 338 |
+
|
| 339 |
+
CREATE VIEW v_goal_progress_summary AS
|
| 340 |
+
SELECT
|
| 341 |
+
g.id, g.name, g.description, g.goal_type, g.target_amount, g.current_amount,
|
| 342 |
+
g.target_date, g.priority, g.is_active, g.completion_percentage,
|
| 343 |
+
COALESCE(SUM(gc.planned_amount), 0) AS total_planned_contributions,
|
| 344 |
+
COALESCE(SUM(gc.actual_amount), 0) AS total_actual_contributions,
|
| 345 |
+
g.target_amount - g.current_amount AS amount_remaining,
|
| 346 |
+
CASE WHEN g.target_date IS NOT NULL
|
| 347 |
+
THEN EXTRACT(MONTH FROM AGE(g.target_date, CURRENT_DATE))
|
| 348 |
+
ELSE NULL
|
| 349 |
+
END AS months_remaining,
|
| 350 |
+
CASE WHEN g.target_date IS NOT NULL
|
| 351 |
+
AND EXTRACT(MONTH FROM AGE(g.target_date, CURRENT_DATE)) > 0
|
| 352 |
+
THEN ROUND((g.target_amount - g.current_amount) /
|
| 353 |
+
EXTRACT(MONTH FROM AGE(g.target_date, CURRENT_DATE)), 2)
|
| 354 |
+
ELSE NULL
|
| 355 |
+
END AS required_monthly_contribution
|
| 356 |
+
FROM budget_goals g
|
| 357 |
+
LEFT JOIN goal_contributions gc ON g.id = gc.goal_id
|
| 358 |
+
GROUP BY g.id, g.name, g.description, g.goal_type, g.target_amount,
|
| 359 |
+
g.current_amount, g.target_date, g.priority, g.is_active, g.completion_percentage;
|
| 360 |
+
|
| 361 |
+
CREATE VIEW v_monthly_contribution_summary AS
|
| 362 |
+
SELECT
|
| 363 |
+
gc.month_year,
|
| 364 |
+
g.name AS goal_name,
|
| 365 |
+
g.goal_type,
|
| 366 |
+
gc.planned_amount,
|
| 367 |
+
gc.actual_amount,
|
| 368 |
+
gc.actual_amount - gc.planned_amount AS variance,
|
| 369 |
+
CASE WHEN gc.planned_amount > 0
|
| 370 |
+
THEN ROUND(gc.actual_amount / gc.planned_amount * 100, 2)
|
| 371 |
+
ELSE 0
|
| 372 |
+
END AS completion_percentage,
|
| 373 |
+
a.name AS source_account_name,
|
| 374 |
+
at.name AS source_account_type,
|
| 375 |
+
gc.notes,
|
| 376 |
+
gc.created_at
|
| 377 |
+
FROM goal_contributions gc
|
| 378 |
+
JOIN budget_goals g ON gc.goal_id = g.id
|
| 379 |
+
LEFT JOIN accounts a ON gc.source_account_id = a.id
|
| 380 |
+
LEFT JOIN account_types at ON a.account_type_id = at.id
|
| 381 |
+
ORDER BY gc.month_year DESC, g.name;
|
| 382 |
+
|
| 383 |
+
-- =============================================================================
|
| 384 |
+
-- 4. SEED DATA
|
| 385 |
+
-- =============================================================================
|
| 386 |
+
|
| 387 |
+
-- 4a. Account Types (6 rows)
|
| 388 |
+
INSERT INTO account_types (id, name, description) VALUES
|
| 389 |
+
(1, 'Bank Account', 'Standard checking and business accounts'),
|
| 390 |
+
(2, 'Investment', 'Investment accounts and portfolios'),
|
| 391 |
+
(3, 'Credit Card', 'Credit card accounts'),
|
| 392 |
+
(4, 'Cash', 'Physical cash on hand'),
|
| 393 |
+
(5, 'Loan', 'Loan accounts'),
|
| 394 |
+
(6, 'Savings Account', 'High-yield savings accounts');
|
| 395 |
+
SELECT setval('account_types_id_seq', 6);
|
| 396 |
+
|
| 397 |
+
-- 4b. Categories (35 rows with hierarchy)
|
| 398 |
+
-- Parent expense categories
|
| 399 |
+
INSERT INTO categories (id, name, parent_category_id, category_type, description) VALUES
|
| 400 |
+
( 1, 'Housing', NULL, 'expense', 'Housing and rent expenses'),
|
| 401 |
+
( 2, 'Food & Dining', NULL, 'expense', 'Groceries and restaurants'),
|
| 402 |
+
( 3, 'Transportation', NULL, 'expense', 'Getting around'),
|
| 403 |
+
( 4, 'Business', NULL, 'expense', 'Business operating expenses'),
|
| 404 |
+
( 5, 'Health & Fitness', NULL, 'expense', 'Health insurance and fitness'),
|
| 405 |
+
( 6, 'Entertainment', NULL, 'expense', 'Fun and leisure'),
|
| 406 |
+
( 7, 'Subscriptions', NULL, 'expense', 'Recurring subscriptions'),
|
| 407 |
+
( 8, 'Utilities', NULL, 'expense', 'Utility bills'),
|
| 408 |
+
( 9, 'Personal Care', NULL, 'expense', 'Personal care and grooming'),
|
| 409 |
+
(10, 'Education', NULL, 'expense', 'Learning and courses');
|
| 410 |
+
|
| 411 |
+
-- Child expense categories
|
| 412 |
+
INSERT INTO categories (id, name, parent_category_id, category_type, description) VALUES
|
| 413 |
+
(11, 'Rent', 1, 'expense', 'Monthly rent payment'),
|
| 414 |
+
(12, 'Groceries', 2, 'expense', 'Grocery shopping'),
|
| 415 |
+
(13, 'Dining Out', 2, 'expense', 'Restaurants and takeout'),
|
| 416 |
+
(14, 'Coffee Shops', 2, 'expense', 'Coffee and cafe visits'),
|
| 417 |
+
(15, 'Uber/Lyft', 3, 'expense', 'Rideshare services'),
|
| 418 |
+
(16, 'Gas', 3, 'expense', 'Fuel for car'),
|
| 419 |
+
(17, 'Software Subscriptions', 4, 'expense', 'SaaS tools for work'),
|
| 420 |
+
(18, 'Coworking', 4, 'expense', 'Coworking space membership'),
|
| 421 |
+
(19, 'Equipment', 4, 'expense', 'Hardware and office equipment'),
|
| 422 |
+
(20, 'Contractors', 4, 'expense', 'Subcontractor payments'),
|
| 423 |
+
(21, 'Marketing', 4, 'expense', 'Advertising and promotion'),
|
| 424 |
+
(22, 'Legal & Accounting', 4, 'expense', 'Professional services'),
|
| 425 |
+
(23, 'Health Insurance', 5, 'expense', 'Monthly health insurance premium'),
|
| 426 |
+
(24, 'Gym', 5, 'expense', 'Gym membership'),
|
| 427 |
+
(25, 'Streaming', 7, 'expense', 'Netflix, Spotify, etc.'),
|
| 428 |
+
(26, 'Cloud Services', 7, 'expense', 'AWS, hosting, domains'),
|
| 429 |
+
(27, 'Internet', 8, 'expense', 'Home internet service'),
|
| 430 |
+
(28, 'Phone', 8, 'expense', 'Mobile phone plan'),
|
| 431 |
+
(29, 'Electricity', 8, 'expense', 'Electric bill');
|
| 432 |
+
|
| 433 |
+
-- Income categories
|
| 434 |
+
INSERT INTO categories (id, name, parent_category_id, category_type, description) VALUES
|
| 435 |
+
(30, 'Client Invoices', NULL, 'income', 'One-time client project payments'),
|
| 436 |
+
(31, 'Recurring Retainers', NULL, 'income', 'Monthly retainer clients'),
|
| 437 |
+
(32, 'Affiliate Income', NULL, 'income', 'Affiliate and referral commissions'),
|
| 438 |
+
(33, 'Interest', NULL, 'income', 'Interest earned on savings');
|
| 439 |
+
|
| 440 |
+
-- Transfer categories
|
| 441 |
+
INSERT INTO categories (id, name, parent_category_id, category_type, description) VALUES
|
| 442 |
+
(34, 'Internal Transfer', NULL, 'transfer', 'Transfers between own accounts'),
|
| 443 |
+
(35, 'Credit Card Payment', NULL, 'transfer', 'Paying off credit card balance');
|
| 444 |
+
|
| 445 |
+
SELECT setval('categories_id_seq', 35);
|
| 446 |
+
|
| 447 |
+
-- 4c. Accounts (11 rows)
|
| 448 |
+
INSERT INTO accounts (id, name, account_type_id, current_balance, is_active, account_number, institution, currency, credit_limit, opening_balance) VALUES
|
| 449 |
+
( 1, 'Chase Personal Checking', 1, 0.00, true, '****4521', 'Chase', 'USD', NULL, 8000.00),
|
| 450 |
+
( 2, 'Chase Business Checking', 1, 0.00, true, '****8903', 'Chase', 'USD', NULL, 3000.00),
|
| 451 |
+
( 3, 'PayPal', 1, 0.00, true, NULL, 'PayPal', 'USD', NULL, 0.00),
|
| 452 |
+
( 4, 'Stripe', 1, 0.00, true, NULL, 'Stripe', 'USD', NULL, 0.00),
|
| 453 |
+
( 5, 'Wise', 1, 0.00, true, NULL, 'Wise', 'USD', NULL, 0.00),
|
| 454 |
+
( 6, 'High-Yield Savings', 6, 0.00, true, '****7712', 'Marcus', 'USD', NULL, 10000.00),
|
| 455 |
+
( 7, 'Cash', 4, 0.00, true, NULL, NULL, 'USD', NULL, 500.00),
|
| 456 |
+
( 8, 'Chase Sapphire', 3, 0.00, true, '****3344', 'Chase', 'USD', 10000.00, 0.00),
|
| 457 |
+
( 9, 'Amex Blue', 3, 0.00, true, '****2211', 'Amex', 'USD', 5000.00, 0.00),
|
| 458 |
+
(10, 'Fidelity Brokerage', 2, 0.00, true, '****9988', 'Fidelity', 'USD', NULL, 20000.00),
|
| 459 |
+
(11, 'Fidelity Roth IRA', 2, 0.00, true, '****5566', 'Fidelity', 'USD', NULL, 8000.00);
|
| 460 |
+
SELECT setval('accounts_id_seq', 11);
|
| 461 |
+
|
| 462 |
+
-- =============================================================================
|
| 463 |
+
-- 4d. Transactions + Entries
|
| 464 |
+
-- Covering Oct 2025 – Jan 2026 (4 months)
|
| 465 |
+
-- =============================================================================
|
| 466 |
+
|
| 467 |
+
-- Helper: We'll use explicit IDs for transactions so entries can reference them.
|
| 468 |
+
|
| 469 |
+
-- ===================== OCTOBER 2025 =====================
|
| 470 |
+
|
| 471 |
+
-- Income
|
| 472 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 473 |
+
(1, '2025-10-03', 'Client: Riverside Marketing - Website Redesign', 'income', 5500.00),
|
| 474 |
+
(2, '2025-10-10', 'Client: TechStart Inc - Monthly Retainer', 'income', 1500.00),
|
| 475 |
+
(3, '2025-10-15', 'Client: GreenLeaf Co - E-commerce Store', 'income', 3200.00),
|
| 476 |
+
(4, '2025-10-20', 'Client: DataFlow Systems - API Integration', 'income', 4000.00),
|
| 477 |
+
(5, '2025-10-25', 'Client: BlueSky Media - Monthly Retainer', 'income', 1500.00);
|
| 478 |
+
|
| 479 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 480 |
+
(1, 4, 30, 5500.00, 'credit', 'Stripe deposit - Riverside Marketing'),
|
| 481 |
+
(2, 2, 31, 1500.00, 'credit', 'Direct deposit - TechStart retainer'),
|
| 482 |
+
(3, 3, 30, 3200.00, 'credit', 'PayPal deposit - GreenLeaf Co'),
|
| 483 |
+
(4, 4, 30, 4000.00, 'credit', 'Stripe deposit - DataFlow Systems'),
|
| 484 |
+
(5, 2, 31, 1500.00, 'credit', 'Direct deposit - BlueSky retainer');
|
| 485 |
+
|
| 486 |
+
-- Expenses - Housing & Utilities
|
| 487 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 488 |
+
(6, '2025-10-01', 'October Rent', 'expense', 1800.00),
|
| 489 |
+
(7, '2025-10-05', 'Internet - Comcast', 'expense', 79.99),
|
| 490 |
+
(8, '2025-10-08', 'Phone - T-Mobile', 'expense', 55.00),
|
| 491 |
+
(9, '2025-10-12', 'Electric Bill - ConEd', 'expense', 95.00);
|
| 492 |
+
|
| 493 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 494 |
+
(6, 1, 11, 1800.00, 'debit', 'Rent payment'),
|
| 495 |
+
(7, 1, 27, 79.99, 'debit', 'Internet service'),
|
| 496 |
+
(8, 1, 28, 55.00, 'debit', 'Mobile plan'),
|
| 497 |
+
(9, 1, 29, 95.00, 'debit', 'Electric bill');
|
| 498 |
+
|
| 499 |
+
-- Expenses - Business
|
| 500 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 501 |
+
(10, '2025-10-01', 'Figma - Pro Plan', 'expense', 15.00),
|
| 502 |
+
(11, '2025-10-01', 'GitHub - Team Plan', 'expense', 25.00),
|
| 503 |
+
(12, '2025-10-01', 'Vercel - Pro Hosting', 'expense', 20.00),
|
| 504 |
+
(13, '2025-10-01', 'Google Workspace', 'expense', 12.00),
|
| 505 |
+
(14, '2025-10-01', 'WeWork - Hot Desk', 'expense', 350.00),
|
| 506 |
+
(15, '2025-10-15', 'Contractor: Sarah - Design Work', 'expense', 800.00),
|
| 507 |
+
(16, '2025-10-20', 'AWS Monthly', 'expense', 45.50);
|
| 508 |
+
|
| 509 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 510 |
+
(10, 9, 17, 15.00, 'debit', 'Figma subscription'),
|
| 511 |
+
(11, 9, 17, 25.00, 'debit', 'GitHub subscription'),
|
| 512 |
+
(12, 9, 17, 20.00, 'debit', 'Vercel hosting'),
|
| 513 |
+
(13, 9, 17, 12.00, 'debit', 'Google Workspace'),
|
| 514 |
+
(14, 2, 18, 350.00, 'debit', 'Coworking monthly'),
|
| 515 |
+
(15, 2, 20, 800.00, 'debit', 'Sarah - UI design for Riverside'),
|
| 516 |
+
(16, 9, 26, 45.50, 'debit', 'AWS hosting');
|
| 517 |
+
|
| 518 |
+
-- Expenses - Food & Dining
|
| 519 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 520 |
+
(17, '2025-10-02', 'Whole Foods', 'expense', 87.30),
|
| 521 |
+
(18, '2025-10-06', 'Trader Joes', 'expense', 62.15),
|
| 522 |
+
(19, '2025-10-09', 'Chipotle', 'expense', 14.50),
|
| 523 |
+
(20, '2025-10-13', 'Whole Foods', 'expense', 95.20),
|
| 524 |
+
(21, '2025-10-16', 'Thai Basil Restaurant', 'expense', 42.00),
|
| 525 |
+
(22, '2025-10-18', 'Starbucks', 'expense', 6.75),
|
| 526 |
+
(23, '2025-10-22', 'Trader Joes', 'expense', 58.40),
|
| 527 |
+
(24, '2025-10-25', 'Shake Shack', 'expense', 18.90),
|
| 528 |
+
(25, '2025-10-28', 'Whole Foods', 'expense', 78.60),
|
| 529 |
+
(26, '2025-10-30', 'Blue Bottle Coffee', 'expense', 5.50);
|
| 530 |
+
|
| 531 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 532 |
+
(17, 8, 12, 87.30, 'debit', 'Weekly groceries'),
|
| 533 |
+
(18, 8, 12, 62.15, 'debit', 'Weekly groceries'),
|
| 534 |
+
(19, 8, 13, 14.50, 'debit', 'Lunch'),
|
| 535 |
+
(20, 8, 12, 95.20, 'debit', 'Weekly groceries'),
|
| 536 |
+
(21, 8, 13, 42.00, 'debit', 'Dinner out'),
|
| 537 |
+
(22, 7, 14, 6.75, 'debit', 'Morning coffee'),
|
| 538 |
+
(23, 8, 12, 58.40, 'debit', 'Weekly groceries'),
|
| 539 |
+
(24, 8, 13, 18.90, 'debit', 'Lunch out'),
|
| 540 |
+
(25, 8, 12, 78.60, 'debit', 'Weekly groceries'),
|
| 541 |
+
(26, 7, 14, 5.50, 'debit', 'Coffee');
|
| 542 |
+
|
| 543 |
+
-- Expenses - Health & Entertainment
|
| 544 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 545 |
+
(27, '2025-10-01', 'Blue Cross - Health Insurance', 'expense', 450.00),
|
| 546 |
+
(28, '2025-10-01', 'Equinox Gym', 'expense', 95.00),
|
| 547 |
+
(29, '2025-10-05', 'Netflix', 'expense', 15.49),
|
| 548 |
+
(30, '2025-10-05', 'Spotify', 'expense', 10.99),
|
| 549 |
+
(31, '2025-10-14', 'Movie Theater - AMC', 'expense', 22.00),
|
| 550 |
+
(32, '2025-10-22', 'Concert Tickets - MSG', 'expense', 120.00);
|
| 551 |
+
|
| 552 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 553 |
+
(27, 1, 23, 450.00, 'debit', 'Monthly health premium'),
|
| 554 |
+
(28, 1, 24, 95.00, 'debit', 'Gym membership'),
|
| 555 |
+
(29, 1, 25, 15.49, 'debit', 'Netflix subscription'),
|
| 556 |
+
(30, 1, 25, 10.99, 'debit', 'Spotify subscription'),
|
| 557 |
+
(31, 8, 6, 22.00, 'debit', 'Movie tickets'),
|
| 558 |
+
(32, 8, 6, 120.00, 'debit', 'Concert');
|
| 559 |
+
|
| 560 |
+
-- Expenses - Transportation
|
| 561 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 562 |
+
(33, '2025-10-07', 'Uber to client meeting', 'expense', 28.50),
|
| 563 |
+
(34, '2025-10-19', 'Lyft to airport', 'expense', 45.00);
|
| 564 |
+
|
| 565 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 566 |
+
(33, 8, 15, 28.50, 'debit', 'Uber ride'),
|
| 567 |
+
(34, 8, 15, 45.00, 'debit', 'Lyft ride');
|
| 568 |
+
|
| 569 |
+
-- Transfers - October
|
| 570 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 571 |
+
(35, '2025-10-05', 'Transfer to savings', 'transfer', 1000.00),
|
| 572 |
+
(36, '2025-10-15', 'Investment contribution', 'transfer', 500.00),
|
| 573 |
+
(37, '2025-10-15', 'Roth IRA contribution', 'transfer', 500.00),
|
| 574 |
+
(38, '2025-10-28', 'Chase Sapphire payment', 'transfer', 500.00),
|
| 575 |
+
(39, '2025-10-10', 'PayPal to Business Checking', 'transfer', 3200.00);
|
| 576 |
+
|
| 577 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 578 |
+
(35, 1, NULL, 1000.00, 'debit', 'To savings'),
|
| 579 |
+
(35, 6, NULL, 1000.00, 'credit', 'From checking'),
|
| 580 |
+
(36, 1, NULL, 500.00, 'debit', 'To brokerage'),
|
| 581 |
+
(36, 10, NULL, 500.00, 'credit', 'Monthly investment'),
|
| 582 |
+
(37, 1, NULL, 500.00, 'debit', 'To Roth IRA'),
|
| 583 |
+
(37, 11, NULL, 500.00, 'credit', 'Monthly Roth contribution'),
|
| 584 |
+
(38, 1, NULL, 500.00, 'debit', 'CC payment'),
|
| 585 |
+
(38, 8, NULL, 500.00, 'credit', 'Payment received'),
|
| 586 |
+
(39, 3, NULL, 3200.00, 'debit', 'Withdraw to bank'),
|
| 587 |
+
(39, 2, NULL, 3200.00, 'credit', 'From PayPal');
|
| 588 |
+
|
| 589 |
+
-- Monthly transfer: business to personal for living expenses
|
| 590 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 591 |
+
(200, '2025-10-02', 'Transfer from Business to Personal', 'transfer', 5000.00);
|
| 592 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 593 |
+
(200, 2, NULL, 5000.00, 'debit', 'To personal checking'),
|
| 594 |
+
(200, 1, NULL, 5000.00, 'credit', 'From business checking');
|
| 595 |
+
|
| 596 |
+
-- ===================== NOVEMBER 2025 =====================
|
| 597 |
+
|
| 598 |
+
-- Income
|
| 599 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 600 |
+
(40, '2025-11-05', 'Client: TechStart Inc - Monthly Retainer', 'income', 1500.00),
|
| 601 |
+
(41, '2025-11-07', 'Client: Artisan Bakery - New Website', 'income', 2800.00),
|
| 602 |
+
(42, '2025-11-12', 'Client: DataFlow Systems - Phase 2', 'income', 6000.00),
|
| 603 |
+
(43, '2025-11-20', 'Client: BlueSky Media - Monthly Retainer', 'income', 1500.00),
|
| 604 |
+
(44, '2025-11-25', 'Affiliate Commission - WPEngine', 'income', 350.00),
|
| 605 |
+
(45, '2025-11-28', 'Client: Peak Performance - Landing Page', 'income', 1800.00);
|
| 606 |
+
|
| 607 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 608 |
+
(40, 2, 31, 1500.00, 'credit', 'Direct deposit - TechStart retainer'),
|
| 609 |
+
(41, 3, 30, 2800.00, 'credit', 'PayPal deposit - Artisan Bakery'),
|
| 610 |
+
(42, 4, 30, 6000.00, 'credit', 'Stripe deposit - DataFlow Systems'),
|
| 611 |
+
(43, 2, 31, 1500.00, 'credit', 'Direct deposit - BlueSky retainer'),
|
| 612 |
+
(44, 3, 32, 350.00, 'credit', 'WPEngine affiliate payout'),
|
| 613 |
+
(45, 5, 30, 1800.00, 'credit', 'Wise deposit - Peak Performance (UK client)');
|
| 614 |
+
|
| 615 |
+
-- Expenses - Housing & Utilities (Nov)
|
| 616 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 617 |
+
(46, '2025-11-01', 'November Rent', 'expense', 1800.00),
|
| 618 |
+
(47, '2025-11-05', 'Internet - Comcast', 'expense', 79.99),
|
| 619 |
+
(48, '2025-11-08', 'Phone - T-Mobile', 'expense', 55.00),
|
| 620 |
+
(49, '2025-11-15', 'Electric Bill - ConEd', 'expense', 110.00);
|
| 621 |
+
|
| 622 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 623 |
+
(46, 1, 11, 1800.00, 'debit', 'Rent payment'),
|
| 624 |
+
(47, 1, 27, 79.99, 'debit', 'Internet service'),
|
| 625 |
+
(48, 1, 28, 55.00, 'debit', 'Mobile plan'),
|
| 626 |
+
(49, 1, 29, 110.00, 'debit', 'Electric bill');
|
| 627 |
+
|
| 628 |
+
-- Expenses - Business (Nov)
|
| 629 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 630 |
+
(50, '2025-11-01', 'Figma - Pro Plan', 'expense', 15.00),
|
| 631 |
+
(51, '2025-11-01', 'GitHub - Team Plan', 'expense', 25.00),
|
| 632 |
+
(52, '2025-11-01', 'Vercel - Pro Hosting', 'expense', 20.00),
|
| 633 |
+
(53, '2025-11-01', 'Google Workspace', 'expense', 12.00),
|
| 634 |
+
(54, '2025-11-01', 'WeWork - Hot Desk', 'expense', 350.00),
|
| 635 |
+
(55, '2025-11-10', 'Contractor: Mike - Backend Dev', 'expense', 1200.00),
|
| 636 |
+
(56, '2025-11-18', 'AWS Monthly', 'expense', 52.30),
|
| 637 |
+
(57, '2025-11-20', 'Adobe Creative Cloud', 'expense', 54.99),
|
| 638 |
+
(58, '2025-11-22', 'Notion - Team Plan', 'expense', 10.00);
|
| 639 |
+
|
| 640 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 641 |
+
(50, 9, 17, 15.00, 'debit', 'Figma subscription'),
|
| 642 |
+
(51, 9, 17, 25.00, 'debit', 'GitHub subscription'),
|
| 643 |
+
(52, 9, 17, 20.00, 'debit', 'Vercel hosting'),
|
| 644 |
+
(53, 9, 17, 12.00, 'debit', 'Google Workspace'),
|
| 645 |
+
(54, 2, 18, 350.00, 'debit', 'Coworking monthly'),
|
| 646 |
+
(55, 2, 20, 1200.00, 'debit', 'Mike - backend for DataFlow'),
|
| 647 |
+
(56, 9, 26, 52.30, 'debit', 'AWS hosting'),
|
| 648 |
+
(57, 9, 17, 54.99, 'debit', 'Adobe CC'),
|
| 649 |
+
(58, 9, 17, 10.00, 'debit', 'Notion subscription');
|
| 650 |
+
|
| 651 |
+
-- Expenses - Food & Dining (Nov)
|
| 652 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 653 |
+
(59, '2025-11-02', 'Whole Foods', 'expense', 92.40),
|
| 654 |
+
(60, '2025-11-06', 'Trader Joes', 'expense', 55.80),
|
| 655 |
+
(61, '2025-11-09', 'Sushi Nakazawa', 'expense', 85.00),
|
| 656 |
+
(62, '2025-11-13', 'Whole Foods', 'expense', 88.10),
|
| 657 |
+
(63, '2025-11-17', 'Panda Express', 'expense', 12.50),
|
| 658 |
+
(64, '2025-11-19', 'Starbucks', 'expense', 7.25),
|
| 659 |
+
(65, '2025-11-23', 'Trader Joes', 'expense', 67.30),
|
| 660 |
+
(66, '2025-11-26', 'Thanksgiving Groceries - Whole Foods', 'expense', 145.00),
|
| 661 |
+
(67, '2025-11-28', 'Blue Bottle Coffee', 'expense', 5.50),
|
| 662 |
+
(68, '2025-11-30', 'Sweetgreen', 'expense', 16.50);
|
| 663 |
+
|
| 664 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 665 |
+
(59, 8, 12, 92.40, 'debit', 'Weekly groceries'),
|
| 666 |
+
(60, 8, 12, 55.80, 'debit', 'Weekly groceries'),
|
| 667 |
+
(61, 8, 13, 85.00, 'debit', 'Dinner out'),
|
| 668 |
+
(62, 8, 12, 88.10, 'debit', 'Weekly groceries'),
|
| 669 |
+
(63, 8, 13, 12.50, 'debit', 'Lunch'),
|
| 670 |
+
(64, 7, 14, 7.25, 'debit', 'Morning coffee'),
|
| 671 |
+
(65, 8, 12, 67.30, 'debit', 'Weekly groceries'),
|
| 672 |
+
(66, 8, 12, 145.00, 'debit', 'Thanksgiving groceries'),
|
| 673 |
+
(67, 7, 14, 5.50, 'debit', 'Coffee'),
|
| 674 |
+
(68, 8, 13, 16.50, 'debit', 'Lunch');
|
| 675 |
+
|
| 676 |
+
-- Expenses - Health & Entertainment (Nov)
|
| 677 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 678 |
+
(69, '2025-11-01', 'Blue Cross - Health Insurance', 'expense', 450.00),
|
| 679 |
+
(70, '2025-11-01', 'Equinox Gym', 'expense', 95.00),
|
| 680 |
+
(71, '2025-11-05', 'Netflix', 'expense', 15.49),
|
| 681 |
+
(72, '2025-11-05', 'Spotify', 'expense', 10.99),
|
| 682 |
+
(73, '2025-11-15', 'Broadway Show', 'expense', 150.00),
|
| 683 |
+
(74, '2025-11-22', 'Video Game - Steam', 'expense', 39.99);
|
| 684 |
+
|
| 685 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 686 |
+
(69, 1, 23, 450.00, 'debit', 'Monthly health premium'),
|
| 687 |
+
(70, 1, 24, 95.00, 'debit', 'Gym membership'),
|
| 688 |
+
(71, 1, 25, 15.49, 'debit', 'Netflix subscription'),
|
| 689 |
+
(72, 1, 25, 10.99, 'debit', 'Spotify subscription'),
|
| 690 |
+
(73, 8, 6, 150.00, 'debit', 'Broadway tickets'),
|
| 691 |
+
(74, 1, 6, 39.99, 'debit', 'Steam purchase');
|
| 692 |
+
|
| 693 |
+
-- Expenses - Transportation (Nov)
|
| 694 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 695 |
+
(75, '2025-11-03', 'Uber to coworking', 'expense', 15.00),
|
| 696 |
+
(76, '2025-11-14', 'Lyft to client dinner', 'expense', 22.50);
|
| 697 |
+
|
| 698 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 699 |
+
(75, 8, 15, 15.00, 'debit', 'Uber ride'),
|
| 700 |
+
(76, 8, 15, 22.50, 'debit', 'Lyft ride');
|
| 701 |
+
|
| 702 |
+
-- Transfers - November
|
| 703 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 704 |
+
(77, '2025-11-05', 'Transfer to savings', 'transfer', 1500.00),
|
| 705 |
+
(78, '2025-11-15', 'Investment contribution', 'transfer', 500.00),
|
| 706 |
+
(79, '2025-11-15', 'Roth IRA contribution', 'transfer', 500.00),
|
| 707 |
+
(80, '2025-11-25', 'Amex Blue payment', 'transfer', 300.00),
|
| 708 |
+
(81, '2025-11-08', 'Stripe to Business Checking', 'transfer', 6000.00),
|
| 709 |
+
(82, '2025-11-26', 'Wise to Business Checking', 'transfer', 1800.00);
|
| 710 |
+
|
| 711 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 712 |
+
(77, 1, NULL, 1500.00, 'debit', 'To savings'),
|
| 713 |
+
(77, 6, NULL, 1500.00, 'credit', 'From checking'),
|
| 714 |
+
(78, 1, NULL, 500.00, 'debit', 'To brokerage'),
|
| 715 |
+
(78, 10, NULL, 500.00, 'credit', 'Monthly investment'),
|
| 716 |
+
(79, 1, NULL, 500.00, 'debit', 'To Roth IRA'),
|
| 717 |
+
(79, 11, NULL, 500.00, 'credit', 'Monthly Roth contribution'),
|
| 718 |
+
(80, 2, NULL, 300.00, 'debit', 'CC payment'),
|
| 719 |
+
(80, 9, NULL, 300.00, 'credit', 'Payment received'),
|
| 720 |
+
(81, 4, NULL, 6000.00, 'debit', 'Withdraw to bank'),
|
| 721 |
+
(81, 2, NULL, 6000.00, 'credit', 'From Stripe'),
|
| 722 |
+
(82, 5, NULL, 1800.00, 'debit', 'Withdraw to bank'),
|
| 723 |
+
(82, 2, NULL, 1800.00, 'credit', 'From Wise');
|
| 724 |
+
|
| 725 |
+
-- Monthly transfer: business to personal for living expenses
|
| 726 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 727 |
+
(201, '2025-11-02', 'Transfer from Business to Personal', 'transfer', 5000.00);
|
| 728 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 729 |
+
(201, 2, NULL, 5000.00, 'debit', 'To personal checking'),
|
| 730 |
+
(201, 1, NULL, 5000.00, 'credit', 'From business checking');
|
| 731 |
+
|
| 732 |
+
-- ===================== DECEMBER 2025 =====================
|
| 733 |
+
|
| 734 |
+
-- Income
|
| 735 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 736 |
+
(83, '2025-12-03', 'Client: TechStart Inc - Monthly Retainer', 'income', 1500.00),
|
| 737 |
+
(84, '2025-12-05', 'Client: Riverside Marketing - Phase 2 Maintenance', 'income', 2000.00),
|
| 738 |
+
(85, '2025-12-10', 'Client: MindfulApps - Mobile App Design', 'income', 7500.00),
|
| 739 |
+
(86, '2025-12-18', 'Client: BlueSky Media - Monthly Retainer', 'income', 1500.00),
|
| 740 |
+
(87, '2025-12-22', 'Client: GreenLeaf Co - Holiday Campaign', 'income', 3000.00),
|
| 741 |
+
(88, '2025-12-28', 'Interest - Marcus Savings', 'income', 52.30);
|
| 742 |
+
|
| 743 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 744 |
+
(83, 2, 31, 1500.00, 'credit', 'Direct deposit - TechStart retainer'),
|
| 745 |
+
(84, 4, 30, 2000.00, 'credit', 'Stripe deposit - Riverside maintenance'),
|
| 746 |
+
(85, 4, 30, 7500.00, 'credit', 'Stripe deposit - MindfulApps'),
|
| 747 |
+
(86, 2, 31, 1500.00, 'credit', 'Direct deposit - BlueSky retainer'),
|
| 748 |
+
(87, 3, 30, 3000.00, 'credit', 'PayPal deposit - GreenLeaf Co'),
|
| 749 |
+
(88, 6, 33, 52.30, 'credit', 'Monthly interest');
|
| 750 |
+
|
| 751 |
+
-- Expenses - Housing & Utilities (Dec)
|
| 752 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 753 |
+
(89, '2025-12-01', 'December Rent', 'expense', 1800.00),
|
| 754 |
+
(90, '2025-12-05', 'Internet - Comcast', 'expense', 79.99),
|
| 755 |
+
(91, '2025-12-08', 'Phone - T-Mobile', 'expense', 55.00),
|
| 756 |
+
(92, '2025-12-18', 'Electric Bill - ConEd', 'expense', 125.00);
|
| 757 |
+
|
| 758 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 759 |
+
(89, 1, 11, 1800.00, 'debit', 'Rent payment'),
|
| 760 |
+
(90, 1, 27, 79.99, 'debit', 'Internet service'),
|
| 761 |
+
(91, 1, 28, 55.00, 'debit', 'Mobile plan'),
|
| 762 |
+
(92, 1, 29, 125.00, 'debit', 'Electric bill - winter');
|
| 763 |
+
|
| 764 |
+
-- Expenses - Business (Dec)
|
| 765 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 766 |
+
(93, '2025-12-01', 'Figma - Pro Plan', 'expense', 15.00),
|
| 767 |
+
(94, '2025-12-01', 'GitHub - Team Plan', 'expense', 25.00),
|
| 768 |
+
(95, '2025-12-01', 'Vercel - Pro Hosting', 'expense', 20.00),
|
| 769 |
+
(96, '2025-12-01', 'Google Workspace', 'expense', 12.00),
|
| 770 |
+
(97, '2025-12-01', 'WeWork - Hot Desk', 'expense', 350.00),
|
| 771 |
+
(98, '2025-12-08', 'Contractor: Sarah - Design Work', 'expense', 600.00),
|
| 772 |
+
(99, '2025-12-15', 'Contractor: Mike - Backend Dev', 'expense', 1500.00),
|
| 773 |
+
(100, '2025-12-20', 'AWS Monthly', 'expense', 58.70),
|
| 774 |
+
(101, '2025-12-01', 'Adobe Creative Cloud', 'expense', 54.99),
|
| 775 |
+
(102, '2025-12-01', 'Notion - Team Plan', 'expense', 10.00),
|
| 776 |
+
(103, '2025-12-10', 'Business lunch with client', 'expense', 85.00);
|
| 777 |
+
|
| 778 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 779 |
+
(93, 9, 17, 15.00, 'debit', 'Figma subscription'),
|
| 780 |
+
(94, 9, 17, 25.00, 'debit', 'GitHub subscription'),
|
| 781 |
+
(95, 9, 17, 20.00, 'debit', 'Vercel hosting'),
|
| 782 |
+
(96, 9, 17, 12.00, 'debit', 'Google Workspace'),
|
| 783 |
+
(97, 2, 18, 350.00, 'debit', 'Coworking monthly'),
|
| 784 |
+
(98, 2, 20, 600.00, 'debit', 'Sarah - design for MindfulApps'),
|
| 785 |
+
(99, 2, 20, 1500.00, 'debit', 'Mike - backend for MindfulApps'),
|
| 786 |
+
(100, 9, 26, 58.70, 'debit', 'AWS hosting'),
|
| 787 |
+
(101, 9, 17, 54.99, 'debit', 'Adobe CC'),
|
| 788 |
+
(102, 9, 17, 10.00, 'debit', 'Notion subscription'),
|
| 789 |
+
(103, 9, 13, 85.00, 'debit', 'Client lunch');
|
| 790 |
+
|
| 791 |
+
-- Expenses - Food & Dining (Dec)
|
| 792 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 793 |
+
(104, '2025-12-01', 'Whole Foods', 'expense', 105.20),
|
| 794 |
+
(105, '2025-12-05', 'Trader Joes', 'expense', 72.40),
|
| 795 |
+
(106, '2025-12-08', 'Italian Restaurant', 'expense', 65.00),
|
| 796 |
+
(107, '2025-12-12', 'Whole Foods', 'expense', 98.30),
|
| 797 |
+
(108, '2025-12-15', 'Starbucks', 'expense', 7.25),
|
| 798 |
+
(109, '2025-12-18', 'Holiday dinner - Nobu', 'expense', 180.00),
|
| 799 |
+
(110, '2025-12-22', 'Trader Joes', 'expense', 85.60),
|
| 800 |
+
(111, '2025-12-24', 'Christmas groceries - Whole Foods', 'expense', 165.00),
|
| 801 |
+
(112, '2025-12-27', 'Blue Bottle Coffee', 'expense', 5.50),
|
| 802 |
+
(113, '2025-12-29', 'Sweetgreen', 'expense', 16.50);
|
| 803 |
+
|
| 804 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 805 |
+
(104, 8, 12, 105.20, 'debit', 'Weekly groceries'),
|
| 806 |
+
(105, 8, 12, 72.40, 'debit', 'Weekly groceries'),
|
| 807 |
+
(106, 8, 13, 65.00, 'debit', 'Dinner out'),
|
| 808 |
+
(107, 8, 12, 98.30, 'debit', 'Weekly groceries'),
|
| 809 |
+
(108, 7, 14, 7.25, 'debit', 'Morning coffee'),
|
| 810 |
+
(109, 8, 13, 180.00, 'debit', 'Holiday dinner'),
|
| 811 |
+
(110, 8, 12, 85.60, 'debit', 'Weekly groceries'),
|
| 812 |
+
(111, 8, 12, 165.00, 'debit', 'Christmas groceries'),
|
| 813 |
+
(112, 7, 14, 5.50, 'debit', 'Coffee'),
|
| 814 |
+
(113, 8, 13, 16.50, 'debit', 'Lunch');
|
| 815 |
+
|
| 816 |
+
-- Expenses - Health & Entertainment (Dec)
|
| 817 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 818 |
+
(114, '2025-12-01', 'Blue Cross - Health Insurance', 'expense', 450.00),
|
| 819 |
+
(115, '2025-12-01', 'Equinox Gym', 'expense', 95.00),
|
| 820 |
+
(116, '2025-12-05', 'Netflix', 'expense', 15.49),
|
| 821 |
+
(117, '2025-12-05', 'Spotify', 'expense', 10.99),
|
| 822 |
+
(118, '2025-12-12', 'Holiday party supplies', 'expense', 75.00),
|
| 823 |
+
(119, '2025-12-20', 'Christmas gifts', 'expense', 350.00);
|
| 824 |
+
|
| 825 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 826 |
+
(114, 1, 23, 450.00, 'debit', 'Monthly health premium'),
|
| 827 |
+
(115, 1, 24, 95.00, 'debit', 'Gym membership'),
|
| 828 |
+
(116, 1, 25, 15.49, 'debit', 'Netflix subscription'),
|
| 829 |
+
(117, 1, 25, 10.99, 'debit', 'Spotify subscription'),
|
| 830 |
+
(118, 8, 6, 75.00, 'debit', 'Party supplies'),
|
| 831 |
+
(119, 8, 6, 350.00, 'debit', 'Christmas gifts');
|
| 832 |
+
|
| 833 |
+
-- Expenses - Transportation (Dec)
|
| 834 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 835 |
+
(120, '2025-12-04', 'Uber to client meeting', 'expense', 32.00),
|
| 836 |
+
(121, '2025-12-15', 'Lyft to holiday party', 'expense', 18.50);
|
| 837 |
+
|
| 838 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 839 |
+
(120, 8, 15, 32.00, 'debit', 'Uber ride'),
|
| 840 |
+
(121, 8, 15, 18.50, 'debit', 'Lyft ride');
|
| 841 |
+
|
| 842 |
+
-- Transfers - December
|
| 843 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 844 |
+
(122, '2025-12-05', 'Transfer to savings', 'transfer', 2000.00),
|
| 845 |
+
(123, '2025-12-15', 'Investment contribution', 'transfer', 1000.00),
|
| 846 |
+
(124, '2025-12-15', 'Roth IRA contribution', 'transfer', 500.00),
|
| 847 |
+
(125, '2025-12-28', 'Chase Sapphire payment', 'transfer', 800.00),
|
| 848 |
+
(126, '2025-12-28', 'Amex Blue payment', 'transfer', 400.00),
|
| 849 |
+
(127, '2025-12-06', 'Stripe to Business Checking', 'transfer', 9500.00),
|
| 850 |
+
(128, '2025-12-08', 'PayPal to Business Checking', 'transfer', 3000.00);
|
| 851 |
+
|
| 852 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 853 |
+
(122, 1, NULL, 2000.00, 'debit', 'To savings'),
|
| 854 |
+
(122, 6, NULL, 2000.00, 'credit', 'From checking'),
|
| 855 |
+
(123, 1, NULL, 1000.00, 'debit', 'To brokerage'),
|
| 856 |
+
(123, 10, NULL, 1000.00, 'credit', 'Monthly investment - extra'),
|
| 857 |
+
(124, 1, NULL, 500.00, 'debit', 'To Roth IRA'),
|
| 858 |
+
(124, 11, NULL, 500.00, 'credit', 'Monthly Roth contribution'),
|
| 859 |
+
(125, 1, NULL, 800.00, 'debit', 'CC payment'),
|
| 860 |
+
(125, 8, NULL, 800.00, 'credit', 'Payment received'),
|
| 861 |
+
(126, 2, NULL, 400.00, 'debit', 'CC payment'),
|
| 862 |
+
(126, 9, NULL, 400.00, 'credit', 'Payment received'),
|
| 863 |
+
(127, 4, NULL, 9500.00, 'debit', 'Withdraw to bank'),
|
| 864 |
+
(127, 2, NULL, 9500.00, 'credit', 'From Stripe'),
|
| 865 |
+
(128, 3, NULL, 3000.00, 'debit', 'Withdraw to bank'),
|
| 866 |
+
(128, 2, NULL, 3000.00, 'credit', 'From PayPal');
|
| 867 |
+
|
| 868 |
+
-- Monthly transfer: business to personal for living expenses
|
| 869 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 870 |
+
(202, '2025-12-02', 'Transfer from Business to Personal', 'transfer', 5500.00);
|
| 871 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 872 |
+
(202, 2, NULL, 5500.00, 'debit', 'To personal checking'),
|
| 873 |
+
(202, 1, NULL, 5500.00, 'credit', 'From business checking');
|
| 874 |
+
|
| 875 |
+
-- ===================== JANUARY 2026 =====================
|
| 876 |
+
|
| 877 |
+
-- Income
|
| 878 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 879 |
+
(129, '2026-01-05', 'Client: TechStart Inc - Monthly Retainer', 'income', 1500.00),
|
| 880 |
+
(130, '2026-01-08', 'Client: MindfulApps - Phase 2', 'income', 4500.00),
|
| 881 |
+
(131, '2026-01-12', 'Client: Summit Analytics - Dashboard', 'income', 5000.00),
|
| 882 |
+
(132, '2026-01-20', 'Client: BlueSky Media - Monthly Retainer', 'income', 1500.00),
|
| 883 |
+
(133, '2026-01-22', 'Client: Peak Performance - Phase 2', 'income', 2500.00),
|
| 884 |
+
(134, '2026-01-28', 'Affiliate Commission - WPEngine', 'income', 280.00),
|
| 885 |
+
(135, '2026-01-30', 'Interest - Marcus Savings', 'income', 58.75);
|
| 886 |
+
|
| 887 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 888 |
+
(129, 2, 31, 1500.00, 'credit', 'Direct deposit - TechStart retainer'),
|
| 889 |
+
(130, 4, 30, 4500.00, 'credit', 'Stripe deposit - MindfulApps'),
|
| 890 |
+
(131, 4, 30, 5000.00, 'credit', 'Stripe deposit - Summit Analytics'),
|
| 891 |
+
(132, 2, 31, 1500.00, 'credit', 'Direct deposit - BlueSky retainer'),
|
| 892 |
+
(133, 5, 30, 2500.00, 'credit', 'Wise deposit - Peak Performance (UK)'),
|
| 893 |
+
(134, 3, 32, 280.00, 'credit', 'WPEngine affiliate payout'),
|
| 894 |
+
(135, 6, 33, 58.75, 'credit', 'Monthly interest');
|
| 895 |
+
|
| 896 |
+
-- Expenses - Housing & Utilities (Jan)
|
| 897 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 898 |
+
(136, '2026-01-01', 'January Rent', 'expense', 1800.00),
|
| 899 |
+
(137, '2026-01-05', 'Internet - Comcast', 'expense', 79.99),
|
| 900 |
+
(138, '2026-01-08', 'Phone - T-Mobile', 'expense', 55.00),
|
| 901 |
+
(139, '2026-01-20', 'Electric Bill - ConEd', 'expense', 140.00);
|
| 902 |
+
|
| 903 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 904 |
+
(136, 1, 11, 1800.00, 'debit', 'Rent payment'),
|
| 905 |
+
(137, 1, 27, 79.99, 'debit', 'Internet service'),
|
| 906 |
+
(138, 1, 28, 55.00, 'debit', 'Mobile plan'),
|
| 907 |
+
(139, 1, 29, 140.00, 'debit', 'Electric bill - winter peak');
|
| 908 |
+
|
| 909 |
+
-- Expenses - Business (Jan)
|
| 910 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 911 |
+
(140, '2026-01-01', 'Figma - Pro Plan', 'expense', 15.00),
|
| 912 |
+
(141, '2026-01-01', 'GitHub - Team Plan', 'expense', 25.00),
|
| 913 |
+
(142, '2026-01-01', 'Vercel - Pro Hosting', 'expense', 20.00),
|
| 914 |
+
(143, '2026-01-01', 'Google Workspace', 'expense', 12.00),
|
| 915 |
+
(144, '2026-01-01', 'WeWork - Hot Desk', 'expense', 350.00),
|
| 916 |
+
(145, '2026-01-10', 'Contractor: Sarah - Design Work', 'expense', 900.00),
|
| 917 |
+
(146, '2026-01-18', 'AWS Monthly', 'expense', 61.20),
|
| 918 |
+
(147, '2026-01-01', 'Adobe Creative Cloud', 'expense', 54.99),
|
| 919 |
+
(148, '2026-01-01', 'Notion - Team Plan', 'expense', 10.00),
|
| 920 |
+
(149, '2026-01-15', 'New monitor - Dell Ultrawide', 'expense', 650.00),
|
| 921 |
+
(150, '2026-01-08', 'Online course - Advanced React Patterns', 'expense', 49.00);
|
| 922 |
+
|
| 923 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 924 |
+
(140, 9, 17, 15.00, 'debit', 'Figma subscription'),
|
| 925 |
+
(141, 9, 17, 25.00, 'debit', 'GitHub subscription'),
|
| 926 |
+
(142, 9, 17, 20.00, 'debit', 'Vercel hosting'),
|
| 927 |
+
(143, 9, 17, 12.00, 'debit', 'Google Workspace'),
|
| 928 |
+
(144, 2, 18, 350.00, 'debit', 'Coworking monthly'),
|
| 929 |
+
(145, 2, 20, 900.00, 'debit', 'Sarah - design for Summit'),
|
| 930 |
+
(146, 9, 26, 61.20, 'debit', 'AWS hosting'),
|
| 931 |
+
(147, 9, 17, 54.99, 'debit', 'Adobe CC'),
|
| 932 |
+
(148, 9, 17, 10.00, 'debit', 'Notion subscription'),
|
| 933 |
+
(149, 9, 19, 650.00, 'debit', 'Dell monitor'),
|
| 934 |
+
(150, 9, 10, 49.00, 'debit', 'React course');
|
| 935 |
+
|
| 936 |
+
-- Expenses - Food & Dining (Jan)
|
| 937 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 938 |
+
(151, '2026-01-03', 'Whole Foods', 'expense', 88.90),
|
| 939 |
+
(152, '2026-01-07', 'Trader Joes', 'expense', 64.20),
|
| 940 |
+
(153, '2026-01-10', 'Ramen Bar', 'expense', 22.00),
|
| 941 |
+
(154, '2026-01-14', 'Whole Foods', 'expense', 91.50),
|
| 942 |
+
(155, '2026-01-17', 'Starbucks', 'expense', 6.75),
|
| 943 |
+
(156, '2026-01-19', 'Dim Sum Palace', 'expense', 55.00),
|
| 944 |
+
(157, '2026-01-23', 'Trader Joes', 'expense', 59.80),
|
| 945 |
+
(158, '2026-01-26', 'Sweetgreen', 'expense', 16.50),
|
| 946 |
+
(159, '2026-01-28', 'Whole Foods', 'expense', 82.40),
|
| 947 |
+
(160, '2026-01-30', 'Blue Bottle Coffee', 'expense', 5.50);
|
| 948 |
+
|
| 949 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 950 |
+
(151, 8, 12, 88.90, 'debit', 'Weekly groceries'),
|
| 951 |
+
(152, 8, 12, 64.20, 'debit', 'Weekly groceries'),
|
| 952 |
+
(153, 8, 13, 22.00, 'debit', 'Lunch out'),
|
| 953 |
+
(154, 8, 12, 91.50, 'debit', 'Weekly groceries'),
|
| 954 |
+
(155, 7, 14, 6.75, 'debit', 'Morning coffee'),
|
| 955 |
+
(156, 8, 13, 55.00, 'debit', 'Dinner out'),
|
| 956 |
+
(157, 8, 12, 59.80, 'debit', 'Weekly groceries'),
|
| 957 |
+
(158, 8, 13, 16.50, 'debit', 'Lunch'),
|
| 958 |
+
(159, 8, 12, 82.40, 'debit', 'Weekly groceries'),
|
| 959 |
+
(160, 7, 14, 5.50, 'debit', 'Coffee');
|
| 960 |
+
|
| 961 |
+
-- Expenses - Health & Entertainment (Jan)
|
| 962 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 963 |
+
(161, '2026-01-01', 'Blue Cross - Health Insurance', 'expense', 450.00),
|
| 964 |
+
(162, '2026-01-01', 'Equinox Gym', 'expense', 95.00),
|
| 965 |
+
(163, '2026-01-05', 'Netflix', 'expense', 15.49),
|
| 966 |
+
(164, '2026-01-05', 'Spotify', 'expense', 10.99),
|
| 967 |
+
(165, '2026-01-18', 'Escape Room', 'expense', 40.00),
|
| 968 |
+
(166, '2026-01-25', 'Comedy Show', 'expense', 60.00);
|
| 969 |
+
|
| 970 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 971 |
+
(161, 1, 23, 450.00, 'debit', 'Monthly health premium'),
|
| 972 |
+
(162, 1, 24, 95.00, 'debit', 'Gym membership'),
|
| 973 |
+
(163, 1, 25, 15.49, 'debit', 'Netflix subscription'),
|
| 974 |
+
(164, 1, 25, 10.99, 'debit', 'Spotify subscription'),
|
| 975 |
+
(165, 8, 6, 40.00, 'debit', 'Escape room'),
|
| 976 |
+
(166, 8, 6, 60.00, 'debit', 'Comedy show');
|
| 977 |
+
|
| 978 |
+
-- Expenses - Transportation (Jan)
|
| 979 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 980 |
+
(167, '2026-01-06', 'Uber to coworking', 'expense', 18.00),
|
| 981 |
+
(168, '2026-01-14', 'Uber to client meeting', 'expense', 25.50),
|
| 982 |
+
(169, '2026-01-22', 'Lyft to airport', 'expense', 48.00);
|
| 983 |
+
|
| 984 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 985 |
+
(167, 8, 15, 18.00, 'debit', 'Uber ride'),
|
| 986 |
+
(168, 8, 15, 25.50, 'debit', 'Uber to client'),
|
| 987 |
+
(169, 8, 15, 48.00, 'debit', 'Lyft to JFK');
|
| 988 |
+
|
| 989 |
+
-- Transfers - January
|
| 990 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 991 |
+
(170, '2026-01-05', 'Transfer to savings', 'transfer', 1500.00),
|
| 992 |
+
(171, '2026-01-15', 'Investment contribution', 'transfer', 1000.00),
|
| 993 |
+
(172, '2026-01-15', 'Roth IRA contribution', 'transfer', 500.00),
|
| 994 |
+
(173, '2026-01-28', 'Chase Sapphire payment', 'transfer', 700.00),
|
| 995 |
+
(174, '2026-01-28', 'Amex Blue payment', 'transfer', 500.00),
|
| 996 |
+
(175, '2026-01-09', 'Stripe to Business Checking', 'transfer', 9500.00),
|
| 997 |
+
(176, '2026-01-23', 'Wise to Business Checking', 'transfer', 2500.00),
|
| 998 |
+
(177, '2026-01-12', 'PayPal to Personal Checking', 'transfer', 280.00);
|
| 999 |
+
|
| 1000 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 1001 |
+
(170, 1, NULL, 1500.00, 'debit', 'To savings'),
|
| 1002 |
+
(170, 6, NULL, 1500.00, 'credit', 'From checking'),
|
| 1003 |
+
(171, 1, NULL, 1000.00, 'debit', 'To brokerage'),
|
| 1004 |
+
(171, 10, NULL, 1000.00, 'credit', 'Monthly investment'),
|
| 1005 |
+
(172, 1, NULL, 500.00, 'debit', 'To Roth IRA'),
|
| 1006 |
+
(172, 11, NULL, 500.00, 'credit', 'Monthly Roth contribution'),
|
| 1007 |
+
(173, 1, NULL, 700.00, 'debit', 'CC payment'),
|
| 1008 |
+
(173, 8, NULL, 700.00, 'credit', 'Payment received'),
|
| 1009 |
+
(174, 2, NULL, 500.00, 'debit', 'CC payment'),
|
| 1010 |
+
(174, 9, NULL, 500.00, 'credit', 'Payment received'),
|
| 1011 |
+
(175, 4, NULL, 9500.00, 'debit', 'Withdraw to bank'),
|
| 1012 |
+
(175, 2, NULL, 9500.00, 'credit', 'From Stripe'),
|
| 1013 |
+
(176, 5, NULL, 2500.00, 'debit', 'Withdraw to bank'),
|
| 1014 |
+
(176, 2, NULL, 2500.00, 'credit', 'From Wise'),
|
| 1015 |
+
(177, 3, NULL, 280.00, 'debit', 'Withdraw to personal'),
|
| 1016 |
+
(177, 1, NULL, 280.00, 'credit', 'From PayPal');
|
| 1017 |
+
|
| 1018 |
+
-- Monthly transfer: business to personal for living expenses
|
| 1019 |
+
INSERT INTO transactions (id, transaction_date, description, transaction_type, total_amount) VALUES
|
| 1020 |
+
(203, '2026-01-02', 'Transfer from Business to Personal', 'transfer', 5500.00);
|
| 1021 |
+
INSERT INTO transaction_entries (transaction_id, account_id, category_id, amount, entry_type, description) VALUES
|
| 1022 |
+
(203, 2, NULL, 5500.00, 'debit', 'To personal checking'),
|
| 1023 |
+
(203, 1, NULL, 5500.00, 'credit', 'From business checking');
|
| 1024 |
+
|
| 1025 |
+
-- Set the sequence to max ID
|
| 1026 |
+
SELECT setval('transactions_id_seq', 203);
|
| 1027 |
+
|
| 1028 |
+
-- =============================================================================
|
| 1029 |
+
-- 5. CALCULATE ACCOUNT BALANCES
|
| 1030 |
+
-- =============================================================================
|
| 1031 |
+
|
| 1032 |
+
-- Reset balances to opening_balance, then apply all entries
|
| 1033 |
+
UPDATE accounts a SET current_balance = a.opening_balance + COALESCE((
|
| 1034 |
+
SELECT SUM(CASE WHEN te.entry_type = 'credit' THEN te.amount ELSE -te.amount END)
|
| 1035 |
+
FROM transaction_entries te
|
| 1036 |
+
WHERE te.account_id = a.id
|
| 1037 |
+
), 0);
|
| 1038 |
+
|
| 1039 |
+
-- =============================================================================
|
| 1040 |
+
-- 6. BUDGETS (Dec 2025 – Feb 2026)
|
| 1041 |
+
-- =============================================================================
|
| 1042 |
+
|
| 1043 |
+
INSERT INTO budgets (category_id, month_year, budgeted_amount) VALUES
|
| 1044 |
+
-- December 2025
|
| 1045 |
+
(11, '2025-12-01', 1800.00), -- Rent
|
| 1046 |
+
(12, '2025-12-01', 500.00), -- Groceries
|
| 1047 |
+
(13, '2025-12-01', 300.00), -- Dining Out
|
| 1048 |
+
(17, '2025-12-01', 250.00), -- Software Subscriptions
|
| 1049 |
+
(18, '2025-12-01', 400.00), -- Coworking
|
| 1050 |
+
(6, '2025-12-01', 300.00), -- Entertainment
|
| 1051 |
+
(25, '2025-12-01', 50.00), -- Streaming
|
| 1052 |
+
-- January 2026
|
| 1053 |
+
(11, '2026-01-01', 1800.00), -- Rent
|
| 1054 |
+
(12, '2026-01-01', 500.00), -- Groceries
|
| 1055 |
+
(13, '2026-01-01', 300.00), -- Dining Out
|
| 1056 |
+
(17, '2026-01-01', 250.00), -- Software Subscriptions
|
| 1057 |
+
(18, '2026-01-01', 400.00), -- Coworking
|
| 1058 |
+
(6, '2026-01-01', 200.00), -- Entertainment
|
| 1059 |
+
(25, '2026-01-01', 50.00), -- Streaming
|
| 1060 |
+
(19, '2026-01-01', 500.00), -- Equipment
|
| 1061 |
+
-- February 2026
|
| 1062 |
+
(11, '2026-02-01', 1800.00), -- Rent
|
| 1063 |
+
(12, '2026-02-01', 500.00), -- Groceries
|
| 1064 |
+
(13, '2026-02-01', 300.00), -- Dining Out
|
| 1065 |
+
(17, '2026-02-01', 250.00), -- Software Subscriptions
|
| 1066 |
+
(18, '2026-02-01', 400.00), -- Coworking
|
| 1067 |
+
(6, '2026-02-01', 200.00), -- Entertainment
|
| 1068 |
+
(25, '2026-02-01', 50.00), -- Streaming
|
| 1069 |
+
(19, '2026-02-01', 500.00); -- Equipment
|
| 1070 |
+
|
| 1071 |
+
-- Update actual_amount from transaction entries
|
| 1072 |
+
UPDATE budgets b SET actual_amount = COALESCE((
|
| 1073 |
+
SELECT SUM(te.amount)
|
| 1074 |
+
FROM transaction_entries te
|
| 1075 |
+
JOIN transactions t ON te.transaction_id = t.id
|
| 1076 |
+
WHERE te.category_id = b.category_id
|
| 1077 |
+
AND t.transaction_type = 'expense'
|
| 1078 |
+
AND date_trunc('month', t.transaction_date::timestamp) = b.month_year
|
| 1079 |
+
), 0);
|
| 1080 |
+
|
| 1081 |
+
-- =============================================================================
|
| 1082 |
+
-- 7. GRANT PERMISSIONS
|
| 1083 |
+
-- =============================================================================
|
| 1084 |
+
|
| 1085 |
+
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO financial_advisor;
|
| 1086 |
+
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO financial_advisor;
|
| 1087 |
+
|
| 1088 |
+
-- =============================================================================
|
| 1089 |
+
-- Done! Demo database ready.
|
| 1090 |
+
-- Run: uv run python app.py
|
| 1091 |
+
-- =============================================================================
|
src/__init__.py
ADDED
|
File without changes
|
src/agent/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.agent.graph import create_agent
|
| 2 |
+
|
| 3 |
+
__all__ = ["create_agent"]
|
src/agent/graph.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langgraph.graph import StateGraph, START, END
|
| 2 |
+
from langgraph.prebuilt import ToolNode
|
| 3 |
+
|
| 4 |
+
from src.agent.state import AgentState
|
| 5 |
+
from src.agent.nodes import call_model, should_continue
|
| 6 |
+
from src.tools import all_tools
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def create_agent(checkpointer=None):
|
| 10 |
+
"""Create and compile the Cashy agent graph.
|
| 11 |
+
|
| 12 |
+
Args:
|
| 13 |
+
checkpointer: Optional LangGraph checkpointer for conversation persistence.
|
| 14 |
+
|
| 15 |
+
Returns:
|
| 16 |
+
Compiled LangGraph agent.
|
| 17 |
+
"""
|
| 18 |
+
tool_node = ToolNode(all_tools)
|
| 19 |
+
|
| 20 |
+
builder = StateGraph(AgentState)
|
| 21 |
+
builder.add_node("agent", call_model)
|
| 22 |
+
builder.add_node("tools", tool_node)
|
| 23 |
+
|
| 24 |
+
builder.add_edge(START, "agent")
|
| 25 |
+
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
|
| 26 |
+
builder.add_edge("tools", "agent")
|
| 27 |
+
|
| 28 |
+
return builder.compile(checkpointer=checkpointer)
|
src/agent/nodes.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from datetime import date
|
| 3 |
+
from langchain_core.messages import SystemMessage
|
| 4 |
+
from langgraph.graph import END
|
| 5 |
+
|
| 6 |
+
from src.agent.state import AgentState
|
| 7 |
+
from src.agent.prompts import get_system_prompt
|
| 8 |
+
from src.config import settings
|
| 9 |
+
from src.tools import all_tools
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger("cashy.agent")
|
| 12 |
+
|
| 13 |
+
# Default models per provider
|
| 14 |
+
DEFAULT_MODELS = {
|
| 15 |
+
"openai": "gpt-5-mini",
|
| 16 |
+
"anthropic": "claude-sonnet-4-20250514",
|
| 17 |
+
"google": "gemini-2.5-flash",
|
| 18 |
+
"huggingface": "meta-llama/Llama-3.3-70B-Instruct",
|
| 19 |
+
"free-tier": "Qwen/Qwen2.5-7B-Instruct",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
# Capture Space's HF token at startup (before BYOK overwrites it)
|
| 23 |
+
_SPACE_HF_TOKEN = settings.hf_token
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def create_model():
|
| 27 |
+
"""Create the LLM chat model with tools bound. Supports multiple providers."""
|
| 28 |
+
provider = settings.resolved_provider
|
| 29 |
+
if not provider:
|
| 30 |
+
raise ValueError(
|
| 31 |
+
"No API key configured. Please select a provider and enter your API key in the sidebar."
|
| 32 |
+
)
|
| 33 |
+
model_name = settings.model_name or DEFAULT_MODELS[provider]
|
| 34 |
+
|
| 35 |
+
logger.info("Initializing LLM: %s (provider=%s)", model_name, provider)
|
| 36 |
+
|
| 37 |
+
if provider == "openai":
|
| 38 |
+
from langchain_openai import ChatOpenAI
|
| 39 |
+
|
| 40 |
+
chat_model = ChatOpenAI(
|
| 41 |
+
model=model_name,
|
| 42 |
+
api_key=settings.openai_api_key,
|
| 43 |
+
max_tokens=settings.model_max_tokens,
|
| 44 |
+
temperature=settings.model_temperature,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
elif provider == "anthropic":
|
| 48 |
+
from langchain_anthropic import ChatAnthropic
|
| 49 |
+
|
| 50 |
+
chat_model = ChatAnthropic(
|
| 51 |
+
model=model_name,
|
| 52 |
+
api_key=settings.anthropic_api_key,
|
| 53 |
+
max_tokens=settings.model_max_tokens,
|
| 54 |
+
temperature=settings.model_temperature,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
elif provider == "google":
|
| 58 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 59 |
+
|
| 60 |
+
chat_model = ChatGoogleGenerativeAI(
|
| 61 |
+
model=model_name,
|
| 62 |
+
google_api_key=settings.google_api_key,
|
| 63 |
+
max_output_tokens=settings.model_max_tokens,
|
| 64 |
+
temperature=settings.model_temperature,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
elif provider == "free-tier":
|
| 68 |
+
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
|
| 69 |
+
|
| 70 |
+
model_name = DEFAULT_MODELS["free-tier"] # always locked
|
| 71 |
+
llm = HuggingFaceEndpoint(
|
| 72 |
+
repo_id=model_name,
|
| 73 |
+
task="text-generation",
|
| 74 |
+
max_new_tokens=settings.model_max_tokens,
|
| 75 |
+
huggingfacehub_api_token=_SPACE_HF_TOKEN,
|
| 76 |
+
)
|
| 77 |
+
chat_model = ChatHuggingFace(llm=llm)
|
| 78 |
+
|
| 79 |
+
elif provider == "huggingface":
|
| 80 |
+
from langchain_huggingface import ChatHuggingFace, HuggingFaceEndpoint
|
| 81 |
+
|
| 82 |
+
llm = HuggingFaceEndpoint(
|
| 83 |
+
repo_id=model_name,
|
| 84 |
+
provider=settings.hf_inference_provider,
|
| 85 |
+
task="text-generation",
|
| 86 |
+
max_new_tokens=settings.model_max_tokens,
|
| 87 |
+
huggingfacehub_api_token=settings.hf_token,
|
| 88 |
+
)
|
| 89 |
+
chat_model = ChatHuggingFace(llm=llm)
|
| 90 |
+
|
| 91 |
+
else:
|
| 92 |
+
raise ValueError(f"Unknown LLM provider: {provider}")
|
| 93 |
+
|
| 94 |
+
tools = _sanitize_tools(all_tools) if provider in ("huggingface", "free-tier") else all_tools
|
| 95 |
+
model = chat_model.bind_tools(tools)
|
| 96 |
+
logger.info("Model ready with %d tools bound", len(all_tools))
|
| 97 |
+
return model
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# Module-level model instance (created once)
|
| 101 |
+
model_with_tools = None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def get_model():
|
| 105 |
+
global model_with_tools
|
| 106 |
+
if model_with_tools is None:
|
| 107 |
+
model_with_tools = create_model()
|
| 108 |
+
return model_with_tools
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def reset_model():
|
| 112 |
+
"""Clear the cached model so the next call creates a fresh one."""
|
| 113 |
+
global model_with_tools
|
| 114 |
+
model_with_tools = None
|
| 115 |
+
logger.info("Model cache cleared — next query will reinitialize")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _sanitize_for_latin1(text: str) -> str:
|
| 119 |
+
"""Replace non-latin-1 Unicode characters for HuggingFace's HTTP transport."""
|
| 120 |
+
result = []
|
| 121 |
+
for c in text:
|
| 122 |
+
try:
|
| 123 |
+
c.encode("latin-1")
|
| 124 |
+
result.append(c)
|
| 125 |
+
except UnicodeEncodeError:
|
| 126 |
+
# Common replacements
|
| 127 |
+
if c in ("\u2014", "\u2013"):
|
| 128 |
+
result.append("-")
|
| 129 |
+
elif c in ("\u201c", "\u201d"):
|
| 130 |
+
result.append('"')
|
| 131 |
+
elif c in ("\u2018", "\u2019"):
|
| 132 |
+
result.append("'")
|
| 133 |
+
elif c == "\u2026":
|
| 134 |
+
result.append("...")
|
| 135 |
+
elif c == "\u2192":
|
| 136 |
+
result.append("->")
|
| 137 |
+
else:
|
| 138 |
+
result.append("?")
|
| 139 |
+
return "".join(result)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _sanitize_tools(tools: list) -> list:
|
| 143 |
+
"""Return copies of tools with latin-1 safe descriptions."""
|
| 144 |
+
import copy
|
| 145 |
+
sanitized = []
|
| 146 |
+
for tool in tools:
|
| 147 |
+
t = copy.deepcopy(tool)
|
| 148 |
+
if hasattr(t, "description"):
|
| 149 |
+
t.description = _sanitize_for_latin1(t.description)
|
| 150 |
+
if hasattr(t, "args_schema") and t.args_schema:
|
| 151 |
+
for field_name, field_info in t.args_schema.model_fields.items():
|
| 152 |
+
if field_info.description:
|
| 153 |
+
field_info.description = _sanitize_for_latin1(field_info.description)
|
| 154 |
+
sanitized.append(t)
|
| 155 |
+
return sanitized
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def call_model(state: AgentState) -> dict:
|
| 159 |
+
"""Invoke the LLM with system prompt and tools."""
|
| 160 |
+
model = get_model()
|
| 161 |
+
today = date.today()
|
| 162 |
+
prompt = get_system_prompt(settings.app_mode).format(today=today.isoformat(), year=today.year)
|
| 163 |
+
messages = [SystemMessage(content=prompt)] + state["messages"]
|
| 164 |
+
|
| 165 |
+
# HuggingFace Inference API requires latin-1 compatible text
|
| 166 |
+
if settings.resolved_provider in ("huggingface", "free-tier"):
|
| 167 |
+
logger.debug("Sanitizing %d messages for latin-1 compatibility", len(messages))
|
| 168 |
+
for msg in messages:
|
| 169 |
+
if isinstance(msg.content, str):
|
| 170 |
+
msg.content = _sanitize_for_latin1(msg.content)
|
| 171 |
+
|
| 172 |
+
logger.debug("Calling LLM (%d messages in state)", len(state["messages"]))
|
| 173 |
+
|
| 174 |
+
response = model.invoke(messages)
|
| 175 |
+
|
| 176 |
+
if response.tool_calls:
|
| 177 |
+
tool_names = [tc["name"] for tc in response.tool_calls]
|
| 178 |
+
logger.info("LLM requested tools: %s", ", ".join(tool_names))
|
| 179 |
+
for tc in response.tool_calls:
|
| 180 |
+
logger.debug(" -> %s(%s)", tc["name"], tc["args"])
|
| 181 |
+
else:
|
| 182 |
+
logger.info("LLM final response (%d chars)", len(response.content))
|
| 183 |
+
|
| 184 |
+
return {"messages": [response]}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def should_continue(state: AgentState) -> str:
|
| 188 |
+
"""Route to tools if the model made tool calls, otherwise end."""
|
| 189 |
+
last_message = state["messages"][-1]
|
| 190 |
+
if last_message.tool_calls:
|
| 191 |
+
logger.debug("Routing to tools node")
|
| 192 |
+
return "tools"
|
| 193 |
+
logger.debug("Routing to END")
|
| 194 |
+
return END
|
src/agent/prompts.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SYSTEM_PROMPT_DEMO = """\
|
| 2 |
+
You are Cashy, a friendly and knowledgeable personal financial advisor AI that helps users manage their finances through a PostgreSQL database.
|
| 3 |
+
|
| 4 |
+
**IMPORTANT: Always respond in English.**
|
| 5 |
+
|
| 6 |
+
**Today's date is {today}.**
|
| 7 |
+
When the user refers to a month without specifying a year, assume the current year ({year}).
|
| 8 |
+
|
| 9 |
+
## Conversational Behavior
|
| 10 |
+
|
| 11 |
+
You are a conversational assistant first. Not every message requires a tool call.
|
| 12 |
+
|
| 13 |
+
- **Greetings** (hello, hi, hey): Respond warmly and briefly explain what you can help with. Do NOT call any tools.
|
| 14 |
+
- **Identity questions** (who are you, what can you do): Answer from your own knowledge. You are Cashy, an AI financial advisor that can check account balances, analyze spending, track budgets, record transactions, and generate charts. Do NOT query the database for this.
|
| 15 |
+
- **General conversation**: Respond naturally. Only use tools when the user asks about their financial data.
|
| 16 |
+
- **Financial questions**: Use the appropriate tool to query real data before responding.
|
| 17 |
+
|
| 18 |
+
## Available Tools
|
| 19 |
+
|
| 20 |
+
You have access to these database tools:
|
| 21 |
+
1. **Finance DB Query** - Execute SQL SELECT queries for custom data retrieval
|
| 22 |
+
2. **Get Account Balance** - Retrieve current balance for specific accounts
|
| 23 |
+
3. **Get Recent Transactions** - View transaction history with filters
|
| 24 |
+
4. **Get Spending by Category** - Get spending breakdown by category for a month
|
| 25 |
+
5. **Get All Accounts** - List all accounts with balances and types
|
| 26 |
+
6. **Create Transaction** - Record new income, expense, or transfer transactions
|
| 27 |
+
7. **Update Transaction** - Modify an existing transaction (description, amount, date, category, notes)
|
| 28 |
+
8. **Delete Transaction** - Remove a transaction and all its entries (irreversible)
|
| 29 |
+
9. **Generate Chart** - Create bar, pie, line, or horizontal bar charts from SQL query results. The chart image is displayed directly in the chat.
|
| 30 |
+
|
| 31 |
+
**IMPORTANT: Prefer the specialized tools (2-8) over Finance DB Query (1).** Only use Finance DB Query when the other tools cannot answer the question.
|
| 32 |
+
|
| 33 |
+
**IMPORTANT: For write operations (create, update, delete), call the tool directly with all required parameters. Do NOT ask the user to confirm first --the tool itself will pause and show a confirmation prompt to the user automatically.** Just gather the necessary details from the user's message and call the tool.
|
| 34 |
+
|
| 35 |
+
## Database Schema (Exact Column Names)
|
| 36 |
+
|
| 37 |
+
When writing SQL queries with Finance DB Query, you MUST use these exact table and column names:
|
| 38 |
+
|
| 39 |
+
**transactions**: id, transaction_date (date), description (varchar), transaction_type (varchar: 'expense'/'income'/'transfer'), total_amount (numeric), reference_number, notes, created_at
|
| 40 |
+
**transaction_entries**: id, transaction_id (FK), account_id (FK), category_id (FK), amount (numeric), entry_type (varchar: 'debit'/'credit'), description (varchar), created_at
|
| 41 |
+
**accounts**: id, name (varchar), account_type_id (FK), current_balance (numeric), is_active (bool), institution (varchar), currency (varchar), credit_limit, opening_balance
|
| 42 |
+
**account_types**: id, name (varchar --exact values: 'Bank Account', 'Investment', 'Credit Card', 'Cash', 'Loan', 'Savings Account'), description
|
| 43 |
+
**categories**: id, name (varchar), parent_category_id (FK self), category_type (varchar: 'expense'/'income'/'transfer'), is_active (bool)
|
| 44 |
+
**budgets**: id, category_id (FK), month_year (date), budgeted_amount, actual_amount, variance_amount
|
| 45 |
+
|
| 46 |
+
**Pre-built views (use these for complex queries):**
|
| 47 |
+
- **v_transaction_details**: transaction_id, transaction_date, transaction_description, transaction_type, total_amount, entry_id, account_name, institution, category_name, parent_category_name, entry_amount, entry_type, entry_description, notes
|
| 48 |
+
- **v_monthly_spending**: month_year, category, category_type, total_amount, transaction_count, average_amount
|
| 49 |
+
- **v_account_summary**: id, name, account_type, current_balance, currency, is_active, last_month_balance, monthly_change
|
| 50 |
+
|
| 51 |
+
**Key relationships:**
|
| 52 |
+
- accounts.account_type_id -> account_types.id
|
| 53 |
+
- transaction_entries.transaction_id -> transactions.id
|
| 54 |
+
- transaction_entries.account_id -> accounts.id
|
| 55 |
+
- transaction_entries.category_id -> categories.id
|
| 56 |
+
- categories.parent_category_id -> categories.id (hierarchy)
|
| 57 |
+
|
| 58 |
+
## SQL Query Rules (for Finance DB Query)
|
| 59 |
+
|
| 60 |
+
- ALWAYS include GROUP BY for every non-aggregated column when using SUM, COUNT, AVG, etc.
|
| 61 |
+
- Use the `v_transaction_details` view for transaction queries --it has pre-joined account names, categories, and proper dates. Its columns are: transaction_id, transaction_date, transaction_description, transaction_type, total_amount, entry_id, account_name, institution, category_name, parent_category_name, entry_amount, entry_type, entry_description, notes.
|
| 62 |
+
- Filter by `transaction_date` for date/month queries, NEVER by `created_at` (which is the record insertion timestamp, not the transaction date).
|
| 63 |
+
- Use `v_monthly_spending` view for spending-by-category summaries.
|
| 64 |
+
- When querying budgets, ALWAYS JOIN categories to get category names.
|
| 65 |
+
- Use exact `account_types.name` values listed above --do not guess spelling.
|
| 66 |
+
- When querying spending or expenses, ALWAYS filter `transaction_type = 'expense'` to exclude transfers. Transfers are debits too but have no category and are not spending.
|
| 67 |
+
|
| 68 |
+
### Example Queries
|
| 69 |
+
|
| 70 |
+
Monthly spending by category:
|
| 71 |
+
```sql
|
| 72 |
+
SELECT category, total_amount FROM v_monthly_spending
|
| 73 |
+
WHERE month_year = '2026-01-01' AND category_type = 'expense'
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
Budget vs actual for a month:
|
| 77 |
+
```sql
|
| 78 |
+
SELECT c.name, b.budgeted_amount, b.actual_amount, b.variance_amount
|
| 79 |
+
FROM budgets b JOIN categories c ON b.category_id = c.id
|
| 80 |
+
WHERE b.month_year = '2026-01-01'
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
Transactions in a category for a month:
|
| 84 |
+
```sql
|
| 85 |
+
SELECT transaction_date, transaction_description, entry_amount, account_name
|
| 86 |
+
FROM v_transaction_details
|
| 87 |
+
WHERE category_name = 'Software Subscriptions'
|
| 88 |
+
AND EXTRACT(MONTH FROM transaction_date) = 1
|
| 89 |
+
AND EXTRACT(YEAR FROM transaction_date) = 2026
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
Accounts by type (e.g., investment accounts):
|
| 93 |
+
```sql
|
| 94 |
+
SELECT name, account_type, current_balance, currency
|
| 95 |
+
FROM v_account_summary
|
| 96 |
+
WHERE account_type = 'Investment' AND is_active = true
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
Client income for a month:
|
| 100 |
+
```sql
|
| 101 |
+
SELECT transaction_date, transaction_description, entry_amount, account_name
|
| 102 |
+
FROM v_transaction_details
|
| 103 |
+
WHERE category_name = 'Client Invoices'
|
| 104 |
+
AND EXTRACT(MONTH FROM transaction_date) = 1
|
| 105 |
+
AND EXTRACT(YEAR FROM transaction_date) = 2026
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
## Output Format Requirements
|
| 109 |
+
|
| 110 |
+
**CRITICAL - Follow these formatting rules exactly:**
|
| 111 |
+
|
| 112 |
+
1. **Currency amounts**: Always format as `$X,XXX.XX` with:
|
| 113 |
+
- Dollar sign prefix
|
| 114 |
+
- Comma thousands separator
|
| 115 |
+
- Exactly two decimal places
|
| 116 |
+
- Example: `$1,234.56` or `$45,230.50`
|
| 117 |
+
|
| 118 |
+
2. **Transaction confirmations**: Respond exactly with:
|
| 119 |
+
- `Transaction recorded` (success)
|
| 120 |
+
- `Error: [specific error description]` (failure)
|
| 121 |
+
|
| 122 |
+
3. **Lists and rankings**: Use numbered format:
|
| 123 |
+
1. Category Name: $X,XXX.XX
|
| 124 |
+
2. Category Name: $X,XXX.XX
|
| 125 |
+
3. Category Name: $X,XXX.XX
|
| 126 |
+
|
| 127 |
+
4. **Dates**: Use ISO format `YYYY-MM-DD` when showing dates explicitly
|
| 128 |
+
|
| 129 |
+
5. **Account balances**: Respond with just the amount unless context is requested:
|
| 130 |
+
- Question: "What's the balance on my Chase account?"
|
| 131 |
+
- Answer: "$8,500.00"
|
| 132 |
+
|
| 133 |
+
6. **Spending totals**: Provide the number directly:
|
| 134 |
+
- Question: "How much did I spend on dining out?"
|
| 135 |
+
- Answer: "$450.00"
|
| 136 |
+
|
| 137 |
+
## Financial Context (Freelancer Accounts)
|
| 138 |
+
|
| 139 |
+
The user is a US-based freelance web developer managing both personal and business finances. Their accounts:
|
| 140 |
+
- **Chase**: Personal checking (daily expenses) and Business checking (client payments, business expenses)
|
| 141 |
+
- **PayPal**: Receives client payments, especially from international clients
|
| 142 |
+
- **Stripe**: Receives payments from clients via invoicing platform
|
| 143 |
+
- **Wise**: International client payments and currency conversion
|
| 144 |
+
- **Marcus (Goldman Sachs)**: High-yield savings account (emergency fund)
|
| 145 |
+
- **Chase Sapphire**: Personal credit card (rewards on travel and dining)
|
| 146 |
+
- **Amex Blue**: Business credit card (business expenses)
|
| 147 |
+
- **Fidelity**: Brokerage and Roth IRA (long-term investments and retirement)
|
| 148 |
+
- **Cash**: Physical cash on hand
|
| 149 |
+
|
| 150 |
+
**Investment queries**: Investments are transfers to/from accounts with `account_type = 'Investment'` (in account_types table). They have `transaction_type = 'transfer'` and typically NO category. To find investment activity, query by account type --not by category:
|
| 151 |
+
```sql
|
| 152 |
+
SELECT t.transaction_date, t.description, te.amount, a.name as account_name
|
| 153 |
+
FROM transactions t
|
| 154 |
+
JOIN transaction_entries te ON te.transaction_id = t.id
|
| 155 |
+
JOIN accounts a ON te.account_id = a.id
|
| 156 |
+
JOIN account_types at ON a.account_type_id = at.id
|
| 157 |
+
WHERE at.name = 'Investment' AND te.entry_type = 'credit'
|
| 158 |
+
AND EXTRACT(MONTH FROM t.transaction_date) = 1
|
| 159 |
+
AND EXTRACT(YEAR FROM t.transaction_date) = 2026
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
## Transaction Recording Rules
|
| 163 |
+
|
| 164 |
+
When creating transactions:
|
| 165 |
+
1. **Expenses**: Use `entry_type='debit'` on the account, amount is positive
|
| 166 |
+
2. **Income**: Use `entry_type='credit'` on the account, amount is positive
|
| 167 |
+
3. **Transfers**: Create two entries - debit from source, credit to destination
|
| 168 |
+
4. **Category selection**: Match user's description to the closest category name
|
| 169 |
+
5. **Descriptions**: Include key details (client name, project, purpose)
|
| 170 |
+
|
| 171 |
+
When updating transactions:
|
| 172 |
+
1. Provide the `transaction_id` (get it from recent transactions or a query first)
|
| 173 |
+
2. Only include the fields that need to change --unchanged fields can be omitted
|
| 174 |
+
3. The user will see current values and proposed changes before confirming
|
| 175 |
+
|
| 176 |
+
When deleting transactions:
|
| 177 |
+
1. Provide the `transaction_id` --always look it up first, never guess
|
| 178 |
+
2. Deletion removes the transaction AND all its entries (irreversible)
|
| 179 |
+
3. The user will see the full transaction details before confirming
|
| 180 |
+
|
| 181 |
+
## Advisory Responses
|
| 182 |
+
|
| 183 |
+
When the user asks for financial advice or recommendations, ALWAYS query relevant data first and base your response on actual numbers. Never give generic advice without checking the data.
|
| 184 |
+
|
| 185 |
+
**Example --User asks: "I need to buy a $1,500 laptop for work, can I afford it?"**
|
| 186 |
+
|
| 187 |
+
Steps the agent MUST follow (use multiple tool calls, do NOT stop after the first one):
|
| 188 |
+
1. Query liquid account balances -> `SELECT name, current_balance FROM v_account_summary WHERE account_type IN ('Bank Account', 'Cash') AND is_active = true`
|
| 189 |
+
2. Query ALL budgets for this month -> `SELECT c.name, b.budgeted_amount, b.actual_amount, b.variance_amount FROM budgets b JOIN categories c ON b.category_id = c.id WHERE b.month_year = '2026-01-01'`
|
| 190 |
+
3. Calculate the impact of the $1,500 on each relevant budget category
|
| 191 |
+
4. Suggest which specific account to use based on balances
|
| 192 |
+
|
| 193 |
+
Expected response style:
|
| 194 |
+
"Your available liquid accounts:
|
| 195 |
+
- Chase Business Checking: $X
|
| 196 |
+
- Chase Personal Checking: $X
|
| 197 |
+
- PayPal: $X
|
| 198 |
+
- Cash: $350.00
|
| 199 |
+
|
| 200 |
+
Your budget for this month:
|
| 201 |
+
- Equipment: budgeted $500.00, spent $0.00, available $500.00
|
| 202 |
+
- Software Subscriptions: budgeted $250.00, spent $180.00, available $70.00
|
| 203 |
+
|
| 204 |
+
The $1,500 laptop exceeds your Equipment budget by $1,000. However, since it's a business expense, you could use your Chase Business Checking ($X available).
|
| 205 |
+
|
| 206 |
+
Since this is a work laptop, it's tax-deductible as a business expense. Consider paying with Amex Blue to earn rewards on the purchase."
|
| 207 |
+
|
| 208 |
+
**Key rules:**
|
| 209 |
+
- ALWAYS query multiple data points: liquid balances, budgets, and current spending
|
| 210 |
+
- Distinguish between personal and business accounts
|
| 211 |
+
- For budget questions: compare budgeted_amount vs actual_amount and show remaining room
|
| 212 |
+
- For purchase decisions: show exact budget impact AND suggest which account to use
|
| 213 |
+
- Always show specific numbers, never give generic tips like "review your spending habits"
|
| 214 |
+
- For business expenses, mention tax deduction implications when relevant
|
| 215 |
+
|
| 216 |
+
## Chart Generation Rules
|
| 217 |
+
|
| 218 |
+
When the user asks for a visualization, chart, graph, or plot:
|
| 219 |
+
1. Use the **Generate Chart** tool with an appropriate chart_type
|
| 220 |
+
2. The sql_query must be a valid SELECT that returns the data to visualize
|
| 221 |
+
3. x_column and y_column must match exact column names from the query result
|
| 222 |
+
4. For comparison charts (e.g., budget vs actual), use y2_column for the second series --this creates grouped bars or a second line
|
| 223 |
+
5. After the chart is generated, briefly describe the key insights from the data
|
| 224 |
+
|
| 225 |
+
Chart type selection:
|
| 226 |
+
- **bar**: Comparing categories (spending by category, budget vs actual)
|
| 227 |
+
- **horizontal_bar**: When category labels are long (account names, descriptions)
|
| 228 |
+
- **pie**: Showing proportions or distribution (% of total spending)
|
| 229 |
+
- **line**: Trends over time (monthly spending, balance history)
|
| 230 |
+
|
| 231 |
+
Comparison chart example (budget vs actual):
|
| 232 |
+
```
|
| 233 |
+
generate_chart(
|
| 234 |
+
chart_type="bar",
|
| 235 |
+
title="Budget vs Actual - January 2026",
|
| 236 |
+
sql_query="SELECT c.name, b.budgeted_amount, b.actual_amount FROM budgets b JOIN categories c ON b.category_id = c.id WHERE b.month_year = '2026-01-01'",
|
| 237 |
+
x_column="name",
|
| 238 |
+
y_column="budgeted_amount",
|
| 239 |
+
y2_column="actual_amount"
|
| 240 |
+
)
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
Do NOT generate a chart unless the user explicitly asks for a visual, chart, graph, or plot. For normal data questions, respond with text and numbers.
|
| 244 |
+
|
| 245 |
+
## Response Guidelines
|
| 246 |
+
|
| 247 |
+
- **Be concise**: Provide the requested information without unnecessary elaboration
|
| 248 |
+
- **Be accurate**: Use exact numbers from the database
|
| 249 |
+
- **Be helpful**: If a query is ambiguous, make a reasonable assumption and state it
|
| 250 |
+
- **Be consistent**: Always follow the output format rules above
|
| 251 |
+
- **Write operations**: Call the tool directly --it handles user confirmation automatically
|
| 252 |
+
- **NEVER show SQL queries in your responses**. Use the tools to execute queries silently and only present the results to the user. Do not describe what query you will run --just run it.
|
| 253 |
+
- **Always execute tools immediately**. Do not say "let me run this query" or "let's check" --call the tool in the same turn.
|
| 254 |
+
|
| 255 |
+
## Error Handling
|
| 256 |
+
|
| 257 |
+
If you cannot fulfill a request:
|
| 258 |
+
1. State clearly what information is missing or why it cannot be done
|
| 259 |
+
2. Suggest what the user should provide or clarify
|
| 260 |
+
3. Never make up data or fabricate transactions\
|
| 261 |
+
"""
|
| 262 |
+
|
| 263 |
+
SYSTEM_PROMPT_PERSONAL = """\
|
| 264 |
+
You are Cashy, a friendly and knowledgeable personal financial advisor AI that helps users manage their finances through a PostgreSQL database.
|
| 265 |
+
|
| 266 |
+
**IMPORTANT: Always respond in English.**
|
| 267 |
+
|
| 268 |
+
**Today's date is {today}.**
|
| 269 |
+
When the user refers to a month without specifying a year, assume the current year ({year}).
|
| 270 |
+
|
| 271 |
+
## Conversational Behavior
|
| 272 |
+
|
| 273 |
+
You are a conversational assistant first. Not every message requires a tool call.
|
| 274 |
+
|
| 275 |
+
- **Greetings** (hello, hi, hey): Respond warmly and briefly explain what you can help with. Do NOT call any tools.
|
| 276 |
+
- **Identity questions** (who are you, what can you do): Answer from your own knowledge. You are Cashy, an AI financial advisor that can check account balances, analyze spending, track budgets, record transactions, and generate charts. Do NOT query the database for this.
|
| 277 |
+
- **General conversation**: Respond naturally. Only use tools when the user asks about their financial data.
|
| 278 |
+
- **Financial questions**: Use the appropriate tool to query real data before responding.
|
| 279 |
+
|
| 280 |
+
## Available Tools
|
| 281 |
+
|
| 282 |
+
You have access to these database tools:
|
| 283 |
+
1. **Finance DB Query** - Execute SQL SELECT queries for custom data retrieval
|
| 284 |
+
2. **Get Account Balance** - Retrieve current balance for specific accounts
|
| 285 |
+
3. **Get Recent Transactions** - View transaction history with filters
|
| 286 |
+
4. **Get Spending by Category** - Get spending breakdown by category for a month
|
| 287 |
+
5. **Get All Accounts** - List all accounts with balances and types
|
| 288 |
+
6. **Create Transaction** - Record new income, expense, or transfer transactions
|
| 289 |
+
7. **Update Transaction** - Modify an existing transaction (description, amount, date, category, notes)
|
| 290 |
+
8. **Delete Transaction** - Remove a transaction and all its entries (irreversible)
|
| 291 |
+
9. **Generate Chart** - Create bar, pie, line, or horizontal bar charts from SQL query results. The chart image is displayed directly in the chat.
|
| 292 |
+
|
| 293 |
+
**IMPORTANT: Prefer the specialized tools (2-8) over Finance DB Query (1).** Only use Finance DB Query when the other tools cannot answer the question.
|
| 294 |
+
|
| 295 |
+
**IMPORTANT: For write operations (create, update, delete), call the tool directly with all required parameters. Do NOT ask the user to confirm first --the tool itself will pause and show a confirmation prompt to the user automatically.** Just gather the necessary details from the user's message and call the tool.
|
| 296 |
+
|
| 297 |
+
## Database Schema (Exact Column Names)
|
| 298 |
+
|
| 299 |
+
When writing SQL queries with Finance DB Query, you MUST use these exact table and column names:
|
| 300 |
+
|
| 301 |
+
**transactions**: id, transaction_date (date), description (varchar), transaction_type (varchar: 'expense'/'income'/'transfer'), total_amount (numeric), reference_number, notes, created_at
|
| 302 |
+
**transaction_entries**: id, transaction_id (FK), account_id (FK), category_id (FK), amount (numeric), entry_type (varchar: 'debit'/'credit'), description (varchar), created_at
|
| 303 |
+
**accounts**: id, name (varchar), account_type_id (FK), current_balance (numeric), is_active (bool), institution (varchar), currency (varchar), credit_limit, opening_balance
|
| 304 |
+
**account_types**: id, name (varchar --exact values: 'Bank Account', 'Investment', 'Credit Card', 'Cash', 'Loan', 'Savings Account'), description
|
| 305 |
+
**categories**: id, name (varchar), parent_category_id (FK self), category_type (varchar: 'expense'/'income'/'transfer'), is_active (bool)
|
| 306 |
+
**budgets**: id, category_id (FK), month_year (date), budgeted_amount, actual_amount, variance_amount
|
| 307 |
+
|
| 308 |
+
**Pre-built views (use these for complex queries):**
|
| 309 |
+
- **v_transaction_details**: transaction_id, transaction_date, transaction_description, transaction_type, total_amount, entry_id, account_name, institution, category_name, parent_category_name, entry_amount, entry_type, entry_description, notes
|
| 310 |
+
- **v_monthly_spending**: month_year, category, category_type, total_amount, transaction_count, average_amount
|
| 311 |
+
- **v_account_summary**: id, name, account_type, current_balance, currency, is_active, last_month_balance, monthly_change
|
| 312 |
+
|
| 313 |
+
**Key relationships:**
|
| 314 |
+
- accounts.account_type_id -> account_types.id
|
| 315 |
+
- transaction_entries.transaction_id -> transactions.id
|
| 316 |
+
- transaction_entries.account_id -> accounts.id
|
| 317 |
+
- transaction_entries.category_id -> categories.id
|
| 318 |
+
- categories.parent_category_id -> categories.id (hierarchy)
|
| 319 |
+
|
| 320 |
+
## SQL Query Rules (for Finance DB Query)
|
| 321 |
+
|
| 322 |
+
- ALWAYS include GROUP BY for every non-aggregated column when using SUM, COUNT, AVG, etc.
|
| 323 |
+
- Use the `v_transaction_details` view for transaction queries --it has pre-joined account names, categories, and proper dates.
|
| 324 |
+
- Filter by `transaction_date` for date/month queries, NEVER by `created_at` (which is the record insertion timestamp, not the transaction date).
|
| 325 |
+
- Use `v_monthly_spending` view for spending-by-category summaries.
|
| 326 |
+
- When querying budgets, ALWAYS JOIN categories to get category names.
|
| 327 |
+
- Use exact `account_types.name` values listed above --do not guess spelling.
|
| 328 |
+
- When querying spending or expenses, ALWAYS filter `transaction_type = 'expense'` to exclude transfers. Transfers are debits too but have no category and are not spending.
|
| 329 |
+
|
| 330 |
+
## Output Format Requirements
|
| 331 |
+
|
| 332 |
+
**CRITICAL - Follow these formatting rules exactly:**
|
| 333 |
+
|
| 334 |
+
1. **Currency amounts**: Always format as `$X,XXX.XX` with dollar sign, comma separator, two decimals.
|
| 335 |
+
2. **Transaction confirmations**: `Transaction recorded` (success) or `Error: [description]` (failure).
|
| 336 |
+
3. **Lists and rankings**: Use numbered format with amounts.
|
| 337 |
+
4. **Dates**: Use ISO format `YYYY-MM-DD`.
|
| 338 |
+
5. **Account balances**: Respond with just the amount unless context is requested.
|
| 339 |
+
6. **Spending totals**: Provide the number directly.
|
| 340 |
+
|
| 341 |
+
## Financial Context
|
| 342 |
+
|
| 343 |
+
The user is managing their personal finances. Start by discovering their accounts and data using the available tools --do NOT assume account names, institutions, or categories. Use `get_all_accounts` to learn the account structure before answering account-specific questions.
|
| 344 |
+
|
| 345 |
+
**Investment queries**: Investments are transfers to/from accounts with `account_type = 'Investment'`. They have `transaction_type = 'transfer'` and typically NO category. Query by account type, not by category.
|
| 346 |
+
|
| 347 |
+
## Transaction Recording Rules
|
| 348 |
+
|
| 349 |
+
When creating transactions:
|
| 350 |
+
1. **Expenses**: Use `entry_type='debit'` on the account, amount is positive
|
| 351 |
+
2. **Income**: Use `entry_type='credit'` on the account, amount is positive
|
| 352 |
+
3. **Transfers**: Create two entries - debit from source, credit to destination
|
| 353 |
+
4. **Category selection**: Match user's description to the closest category name
|
| 354 |
+
5. **Descriptions**: Include key details (client name, project, purpose)
|
| 355 |
+
|
| 356 |
+
When updating transactions:
|
| 357 |
+
1. Provide the `transaction_id` (get it from recent transactions or a query first)
|
| 358 |
+
2. Only include the fields that need to change --unchanged fields can be omitted
|
| 359 |
+
3. The user will see current values and proposed changes before confirming
|
| 360 |
+
|
| 361 |
+
When deleting transactions:
|
| 362 |
+
1. Provide the `transaction_id` --always look it up first, never guess
|
| 363 |
+
2. Deletion removes the transaction AND all its entries (irreversible)
|
| 364 |
+
3. The user will see the full transaction details before confirming
|
| 365 |
+
|
| 366 |
+
## Advisory Responses
|
| 367 |
+
|
| 368 |
+
When the user asks for financial advice or recommendations, ALWAYS query relevant data first and base your response on actual numbers. Never give generic advice without checking the data. Query multiple data points: liquid balances, budgets, and current spending. Always show specific numbers.
|
| 369 |
+
|
| 370 |
+
## Chart Generation Rules
|
| 371 |
+
|
| 372 |
+
When the user asks for a visualization, chart, graph, or plot:
|
| 373 |
+
1. Use the **Generate Chart** tool with an appropriate chart_type
|
| 374 |
+
2. The sql_query must be a valid SELECT that returns the data to visualize
|
| 375 |
+
3. x_column and y_column must match exact column names from the query result
|
| 376 |
+
4. For comparison charts, use y2_column for the second series
|
| 377 |
+
5. After the chart is generated, briefly describe the key insights
|
| 378 |
+
|
| 379 |
+
Chart type selection: bar (categories), horizontal_bar (long labels), pie (proportions), line (trends over time).
|
| 380 |
+
|
| 381 |
+
Do NOT generate a chart unless the user explicitly asks for a visual, chart, graph, or plot.
|
| 382 |
+
|
| 383 |
+
## Response Guidelines
|
| 384 |
+
|
| 385 |
+
- **Be concise**: Provide the requested information without unnecessary elaboration
|
| 386 |
+
- **Be accurate**: Use exact numbers from the database
|
| 387 |
+
- **Be helpful**: If a query is ambiguous, make a reasonable assumption and state it
|
| 388 |
+
- **Be consistent**: Always follow the output format rules above
|
| 389 |
+
- **Write operations**: Call the tool directly --it handles user confirmation automatically
|
| 390 |
+
- **NEVER show SQL queries in your responses**. Use the tools to execute queries silently and only present the results to the user. Do not describe what query you will run --just run it.
|
| 391 |
+
- **Always execute tools immediately**. Do not say "let me run this query" or "let's check" --call the tool in the same turn.
|
| 392 |
+
|
| 393 |
+
## Error Handling
|
| 394 |
+
|
| 395 |
+
If you cannot fulfill a request:
|
| 396 |
+
1. State clearly what information is missing or why it cannot be done
|
| 397 |
+
2. Suggest what the user should provide or clarify
|
| 398 |
+
3. Never make up data or fabricate transactions\
|
| 399 |
+
"""
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def get_system_prompt(mode: str) -> str:
|
| 403 |
+
"""Return the system prompt for the given app mode."""
|
| 404 |
+
if mode == "personal":
|
| 405 |
+
return SYSTEM_PROMPT_PERSONAL
|
| 406 |
+
return SYSTEM_PROMPT_DEMO
|
src/agent/state.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langgraph.graph import MessagesState
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class AgentState(MessagesState):
|
| 5 |
+
"""Agent state extending MessagesState.
|
| 6 |
+
Add custom keys here if needed later (e.g., user_id, session metadata)."""
|
| 7 |
+
pass
|
src/config.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Literal, Optional
|
| 2 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
model_config = SettingsConfigDict(
|
| 7 |
+
env_file=".env",
|
| 8 |
+
env_file_encoding="utf-8",
|
| 9 |
+
extra="ignore",
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
# App mode — "demo" (seeded showcase) or "personal" (real financial data)
|
| 13 |
+
app_mode: Literal["demo", "personal"] = "personal"
|
| 14 |
+
|
| 15 |
+
# Database — auto-reads DB_HOST, DB_PORT, etc. from .env
|
| 16 |
+
db_host: str = "localhost"
|
| 17 |
+
db_port: int = 5432
|
| 18 |
+
db_name: str = "financial_db" # fallback; overridden by resolved_db_name
|
| 19 |
+
db_name_demo: str = "cashy_demo"
|
| 20 |
+
db_name_personal: str = "financial_db"
|
| 21 |
+
db_user: str = "financial_advisor"
|
| 22 |
+
db_password: str = ""
|
| 23 |
+
db_sslmode: str = "" # "require" for Neon; empty for local
|
| 24 |
+
|
| 25 |
+
# LLM provider — set explicitly or auto-detected from API keys
|
| 26 |
+
llm_provider: Optional[Literal["openai", "anthropic", "google", "huggingface", "free-tier", ""]] = None
|
| 27 |
+
|
| 28 |
+
# Per-provider API keys (only one needed)
|
| 29 |
+
openai_api_key: str = ""
|
| 30 |
+
anthropic_api_key: str = ""
|
| 31 |
+
google_api_key: str = ""
|
| 32 |
+
hf_token: str = ""
|
| 33 |
+
|
| 34 |
+
# Model configuration
|
| 35 |
+
model_name: str = "" # Optional override; defaults per provider
|
| 36 |
+
model_max_tokens: int = 512
|
| 37 |
+
model_temperature: float = 0.1
|
| 38 |
+
|
| 39 |
+
# HuggingFace-specific
|
| 40 |
+
hf_inference_provider: str = "together"
|
| 41 |
+
|
| 42 |
+
# LangSmith — auto-reads LANGSMITH_* from .env
|
| 43 |
+
langsmith_tracing: str = "true"
|
| 44 |
+
langsmith_api_key: str = ""
|
| 45 |
+
langsmith_project: str = "cashy-financial-advisor"
|
| 46 |
+
|
| 47 |
+
# App
|
| 48 |
+
environment: str = "development"
|
| 49 |
+
debug: bool = True
|
| 50 |
+
|
| 51 |
+
@property
|
| 52 |
+
def resolved_db_name(self) -> str:
|
| 53 |
+
"""Return the database name based on app_mode."""
|
| 54 |
+
if self.app_mode == "demo":
|
| 55 |
+
return self.db_name_demo
|
| 56 |
+
return self.db_name_personal
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def database_url(self) -> str:
|
| 60 |
+
"""Build DATABASE_URL from individual DB components."""
|
| 61 |
+
url = (
|
| 62 |
+
f"postgresql://{self.db_user}:{self.db_password}"
|
| 63 |
+
f"@{self.db_host}:{self.db_port}/{self.resolved_db_name}"
|
| 64 |
+
)
|
| 65 |
+
if self.db_sslmode:
|
| 66 |
+
url += f"?sslmode={self.db_sslmode}"
|
| 67 |
+
return url
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def database_url_safe(self) -> str:
|
| 71 |
+
"""Database URL with password redacted for logging."""
|
| 72 |
+
return self.database_url.replace(f":{self.db_password}@", ":***@")
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def resolved_provider(self) -> Optional[str]:
|
| 76 |
+
"""Return the active LLM provider: explicit setting, auto-detected from keys, or None."""
|
| 77 |
+
if self.llm_provider and self.llm_provider != "":
|
| 78 |
+
return self.llm_provider
|
| 79 |
+
|
| 80 |
+
# Auto-detect from populated API keys (priority order)
|
| 81 |
+
if self.openai_api_key and self.openai_api_key != "sk-...":
|
| 82 |
+
return "openai"
|
| 83 |
+
if self.anthropic_api_key and self.anthropic_api_key != "sk-ant-...":
|
| 84 |
+
return "anthropic"
|
| 85 |
+
if self.google_api_key and self.google_api_key != "AI...":
|
| 86 |
+
return "google"
|
| 87 |
+
if self.hf_token and self.hf_token != "hf_...":
|
| 88 |
+
# In demo mode, default to free-tier (user can switch to huggingface BYOK)
|
| 89 |
+
if self.app_mode == "demo":
|
| 90 |
+
return "free-tier"
|
| 91 |
+
return "huggingface"
|
| 92 |
+
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
settings = Settings()
|
src/db/__init__.py
ADDED
|
File without changes
|
src/db/connection.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
import logging
|
| 3 |
+
import psycopg2
|
| 4 |
+
from contextlib import contextmanager
|
| 5 |
+
from src.config import settings
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("cashy.db")
|
| 8 |
+
|
| 9 |
+
NEON_RETRY_DELAY = 3 # seconds to wait for Neon cold start
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _connect():
|
| 13 |
+
"""Create a psycopg2 connection, retrying once on cold-start failures."""
|
| 14 |
+
conn_kwargs = dict(
|
| 15 |
+
host=settings.db_host,
|
| 16 |
+
port=settings.db_port,
|
| 17 |
+
dbname=settings.resolved_db_name,
|
| 18 |
+
user=settings.db_user,
|
| 19 |
+
password=settings.db_password,
|
| 20 |
+
)
|
| 21 |
+
if settings.db_sslmode:
|
| 22 |
+
conn_kwargs["sslmode"] = settings.db_sslmode
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
return psycopg2.connect(**conn_kwargs)
|
| 26 |
+
except psycopg2.OperationalError:
|
| 27 |
+
logger.info("DB connection failed -- retrying in %ds (Neon cold start?)", NEON_RETRY_DELAY)
|
| 28 |
+
time.sleep(NEON_RETRY_DELAY)
|
| 29 |
+
return psycopg2.connect(**conn_kwargs)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@contextmanager
|
| 33 |
+
def get_connection():
|
| 34 |
+
"""Context manager for database connections.
|
| 35 |
+
|
| 36 |
+
Usage:
|
| 37 |
+
with get_connection() as conn:
|
| 38 |
+
with conn.cursor() as cur:
|
| 39 |
+
cur.execute("SELECT ...")
|
| 40 |
+
rows = cur.fetchall()
|
| 41 |
+
"""
|
| 42 |
+
logger.debug("Opening DB connection")
|
| 43 |
+
conn = _connect()
|
| 44 |
+
try:
|
| 45 |
+
yield conn
|
| 46 |
+
except Exception:
|
| 47 |
+
logger.warning("DB error -- rolling back")
|
| 48 |
+
conn.rollback()
|
| 49 |
+
raise
|
| 50 |
+
else:
|
| 51 |
+
conn.commit()
|
| 52 |
+
finally:
|
| 53 |
+
conn.close()
|
| 54 |
+
logger.debug("DB connection closed")
|
src/tools/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from src.tools.finance_query import finance_db_query
|
| 2 |
+
from src.tools.account_balance import get_account_balance
|
| 3 |
+
from src.tools.recent_transactions import get_recent_transactions
|
| 4 |
+
from src.tools.spending_by_category import get_spending_by_category
|
| 5 |
+
from src.tools.all_accounts import get_all_accounts
|
| 6 |
+
from src.tools.create_transaction import create_transaction
|
| 7 |
+
from src.tools.update_transaction import update_transaction
|
| 8 |
+
from src.tools.delete_transaction import delete_transaction
|
| 9 |
+
from src.tools.generate_chart import generate_chart
|
| 10 |
+
|
| 11 |
+
all_tools = [
|
| 12 |
+
finance_db_query,
|
| 13 |
+
get_account_balance,
|
| 14 |
+
get_recent_transactions,
|
| 15 |
+
get_spending_by_category,
|
| 16 |
+
get_all_accounts,
|
| 17 |
+
create_transaction,
|
| 18 |
+
update_transaction,
|
| 19 |
+
delete_transaction,
|
| 20 |
+
generate_chart,
|
| 21 |
+
]
|
src/tools/account_balance.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from langchain_core.tools import tool
|
| 4 |
+
from src.db.connection import get_connection
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("cashy.tools")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@tool
|
| 10 |
+
def get_account_balance(account_name: str) -> str:
|
| 11 |
+
"""Get the current balance for a specific account by name (e.g., 'BBVA', 'NU', 'Santander')."""
|
| 12 |
+
logger.info("[get_account_balance] account_name=%s", account_name)
|
| 13 |
+
try:
|
| 14 |
+
with get_connection() as conn:
|
| 15 |
+
with conn.cursor() as cur:
|
| 16 |
+
cur.execute(
|
| 17 |
+
"""
|
| 18 |
+
SELECT a.name, a.current_balance, at.name as account_type,
|
| 19 |
+
a.currency, a.institution
|
| 20 |
+
FROM accounts a
|
| 21 |
+
JOIN account_types at ON a.account_type_id = at.id
|
| 22 |
+
WHERE a.name ILIKE %s AND a.is_active = true
|
| 23 |
+
""",
|
| 24 |
+
(f"%{account_name}%",),
|
| 25 |
+
)
|
| 26 |
+
result = cur.fetchone()
|
| 27 |
+
|
| 28 |
+
if result:
|
| 29 |
+
logger.info("[get_account_balance] Found: %s = %s %s", result[0], result[1], result[3])
|
| 30 |
+
return json.dumps(
|
| 31 |
+
{
|
| 32 |
+
"success": True,
|
| 33 |
+
"account_name": result[0],
|
| 34 |
+
"balance": float(result[1]),
|
| 35 |
+
"account_type": result[2],
|
| 36 |
+
"currency": result[3],
|
| 37 |
+
"institution": result[4] or "N/A",
|
| 38 |
+
},
|
| 39 |
+
default=str,
|
| 40 |
+
)
|
| 41 |
+
else:
|
| 42 |
+
logger.warning("[get_account_balance] Account '%s' not found", account_name)
|
| 43 |
+
return json.dumps(
|
| 44 |
+
{"success": False, "error": f"Account '{account_name}' not found"}
|
| 45 |
+
)
|
| 46 |
+
except Exception as e:
|
| 47 |
+
logger.error("[get_account_balance] Error: %s", e)
|
| 48 |
+
return json.dumps({"success": False, "error": str(e)})
|
src/tools/all_accounts.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from langchain_core.tools import tool
|
| 4 |
+
from src.db.connection import get_connection
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("cashy.tools")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@tool
|
| 10 |
+
def get_all_accounts(include_inactive: bool = False) -> str:
|
| 11 |
+
"""Get a list of all user accounts with their balances, types, and institutions."""
|
| 12 |
+
logger.info("[get_all_accounts] include_inactive=%s", include_inactive)
|
| 13 |
+
try:
|
| 14 |
+
with get_connection() as conn:
|
| 15 |
+
with conn.cursor() as cur:
|
| 16 |
+
query = """
|
| 17 |
+
SELECT a.name, a.current_balance, at.name as account_type,
|
| 18 |
+
a.currency, a.institution, a.is_active
|
| 19 |
+
FROM accounts a
|
| 20 |
+
JOIN account_types at ON a.account_type_id = at.id
|
| 21 |
+
"""
|
| 22 |
+
if not include_inactive:
|
| 23 |
+
query += " WHERE a.is_active = true"
|
| 24 |
+
query += " ORDER BY at.name, a.name"
|
| 25 |
+
|
| 26 |
+
cur.execute(query)
|
| 27 |
+
columns = [desc[0] for desc in cur.description]
|
| 28 |
+
rows = cur.fetchall()
|
| 29 |
+
results = [dict(zip(columns, row)) for row in rows]
|
| 30 |
+
|
| 31 |
+
logger.info("[get_all_accounts] Returned %d accounts", len(results))
|
| 32 |
+
return json.dumps(
|
| 33 |
+
{"success": True, "count": len(results), "accounts": results},
|
| 34 |
+
default=str,
|
| 35 |
+
)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
logger.error("[get_all_accounts] Error: %s", e)
|
| 38 |
+
return json.dumps({"success": False, "error": str(e)})
|
src/tools/create_transaction.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from langchain_core.tools import tool
|
| 5 |
+
from langgraph.types import interrupt
|
| 6 |
+
from src.db.connection import get_connection
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger("cashy.tools")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@tool
|
| 12 |
+
def create_transaction(
|
| 13 |
+
transaction_type: str,
|
| 14 |
+
transaction_description: str,
|
| 15 |
+
amount: float,
|
| 16 |
+
account_name: str,
|
| 17 |
+
category_name: str = "",
|
| 18 |
+
date: str = "",
|
| 19 |
+
notes: str = "",
|
| 20 |
+
) -> str:
|
| 21 |
+
"""Create a new financial transaction (expense, income, or transfer).
|
| 22 |
+
transaction_type must be 'expense', 'income', or 'transfer'.
|
| 23 |
+
amount must be a positive number.
|
| 24 |
+
date format: YYYY-MM-DD (optional, defaults to today).
|
| 25 |
+
The user will be asked to confirm before the transaction is created."""
|
| 26 |
+
logger.info("[create_transaction] type=%s amount=%.2f account=%s category=%s",
|
| 27 |
+
transaction_type, amount, account_name, category_name or "none")
|
| 28 |
+
|
| 29 |
+
# --- Validation ---
|
| 30 |
+
if amount <= 0:
|
| 31 |
+
return json.dumps({"success": False, "error": "Amount must be positive"})
|
| 32 |
+
|
| 33 |
+
if transaction_type not in ("expense", "income", "transfer"):
|
| 34 |
+
return json.dumps(
|
| 35 |
+
{"success": False, "error": "transaction_type must be 'expense', 'income', or 'transfer'"}
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
if date and date.strip():
|
| 39 |
+
try:
|
| 40 |
+
transaction_date = datetime.strptime(date.strip(), "%Y-%m-%d").date()
|
| 41 |
+
except ValueError:
|
| 42 |
+
return json.dumps({"success": False, "error": "Invalid date format. Use YYYY-MM-DD"})
|
| 43 |
+
else:
|
| 44 |
+
transaction_date = datetime.now().date()
|
| 45 |
+
|
| 46 |
+
# --- Resolve names to IDs ---
|
| 47 |
+
try:
|
| 48 |
+
with get_connection() as conn:
|
| 49 |
+
with conn.cursor() as cur:
|
| 50 |
+
cur.execute(
|
| 51 |
+
"SELECT id, name FROM accounts WHERE name ILIKE %s AND is_active = true",
|
| 52 |
+
(f"%{account_name}%",),
|
| 53 |
+
)
|
| 54 |
+
account = cur.fetchone()
|
| 55 |
+
if not account:
|
| 56 |
+
return json.dumps(
|
| 57 |
+
{"success": False, "error": f"Account '{account_name}' not found"}
|
| 58 |
+
)
|
| 59 |
+
account_id, account_full_name = account
|
| 60 |
+
|
| 61 |
+
category_id = None
|
| 62 |
+
category_full_name = "Uncategorized"
|
| 63 |
+
if category_name and category_name.strip():
|
| 64 |
+
cur.execute(
|
| 65 |
+
"SELECT id, name FROM categories WHERE name ILIKE %s AND is_active = true",
|
| 66 |
+
(f"%{category_name}%",),
|
| 67 |
+
)
|
| 68 |
+
category = cur.fetchone()
|
| 69 |
+
if category:
|
| 70 |
+
category_id, category_full_name = category
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.error("[create_transaction] Lookup error: %s", e)
|
| 73 |
+
return json.dumps({"success": False, "error": str(e)})
|
| 74 |
+
|
| 75 |
+
entry_type = "credit" if transaction_type == "income" else "debit"
|
| 76 |
+
desc = transaction_description.strip()
|
| 77 |
+
note = notes.strip() if notes and notes.strip() else None
|
| 78 |
+
|
| 79 |
+
# --- Confirmation gate ---
|
| 80 |
+
confirmation = {
|
| 81 |
+
"action": "create_transaction",
|
| 82 |
+
"message": f"Create {transaction_type} of ${amount:.2f} on {account_full_name}?",
|
| 83 |
+
"details": {
|
| 84 |
+
"type": transaction_type,
|
| 85 |
+
"amount": float(amount),
|
| 86 |
+
"account": account_full_name,
|
| 87 |
+
"category": category_full_name,
|
| 88 |
+
"date": str(transaction_date),
|
| 89 |
+
"description": desc,
|
| 90 |
+
},
|
| 91 |
+
}
|
| 92 |
+
response = interrupt(confirmation)
|
| 93 |
+
|
| 94 |
+
if not response.get("approved"):
|
| 95 |
+
logger.info("[create_transaction] Cancelled by user")
|
| 96 |
+
return json.dumps({"success": False, "message": "Transaction cancelled by user"})
|
| 97 |
+
|
| 98 |
+
# --- Execute DB write ---
|
| 99 |
+
try:
|
| 100 |
+
with get_connection() as conn:
|
| 101 |
+
with conn.cursor() as cur:
|
| 102 |
+
cur.execute(
|
| 103 |
+
"""
|
| 104 |
+
INSERT INTO transactions
|
| 105 |
+
(transaction_date, description, transaction_type, total_amount, notes)
|
| 106 |
+
VALUES (%s, %s, %s, %s, %s)
|
| 107 |
+
RETURNING id
|
| 108 |
+
""",
|
| 109 |
+
(transaction_date, desc, transaction_type, amount, note),
|
| 110 |
+
)
|
| 111 |
+
transaction_id = cur.fetchone()[0]
|
| 112 |
+
|
| 113 |
+
cur.execute(
|
| 114 |
+
"""
|
| 115 |
+
INSERT INTO transaction_entries
|
| 116 |
+
(transaction_id, account_id, category_id, amount, entry_type, description)
|
| 117 |
+
VALUES (%s, %s, %s, %s, %s, %s)
|
| 118 |
+
RETURNING id
|
| 119 |
+
""",
|
| 120 |
+
(transaction_id, account_id, category_id, amount, entry_type, desc),
|
| 121 |
+
)
|
| 122 |
+
entry_id = cur.fetchone()[0]
|
| 123 |
+
|
| 124 |
+
logger.info("[create_transaction] Created txn_id=%d entry_id=%d", transaction_id, entry_id)
|
| 125 |
+
return json.dumps(
|
| 126 |
+
{
|
| 127 |
+
"success": True,
|
| 128 |
+
"transaction_id": transaction_id,
|
| 129 |
+
"entry_id": entry_id,
|
| 130 |
+
"message": f"{transaction_type.title()} of ${amount:.2f} recorded on {transaction_date}",
|
| 131 |
+
"details": {
|
| 132 |
+
"type": transaction_type,
|
| 133 |
+
"amount": float(amount),
|
| 134 |
+
"account": account_full_name,
|
| 135 |
+
"category": category_full_name,
|
| 136 |
+
"date": str(transaction_date),
|
| 137 |
+
"description": desc,
|
| 138 |
+
},
|
| 139 |
+
},
|
| 140 |
+
default=str,
|
| 141 |
+
)
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.error("[create_transaction] Error: %s", e)
|
| 144 |
+
return json.dumps({"success": False, "error": str(e)})
|
src/tools/delete_transaction.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from langchain_core.tools import tool
|
| 4 |
+
from langgraph.types import interrupt
|
| 5 |
+
from src.db.connection import get_connection
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("cashy.tools")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@tool
|
| 11 |
+
def delete_transaction(transaction_id: int) -> str:
|
| 12 |
+
"""Delete a transaction and all its entries. This is irreversible.
|
| 13 |
+
The user will be asked to confirm before the deletion is executed."""
|
| 14 |
+
logger.info("[delete_transaction] id=%d", transaction_id)
|
| 15 |
+
|
| 16 |
+
# Fetch transaction details for confirmation display
|
| 17 |
+
try:
|
| 18 |
+
with get_connection() as conn:
|
| 19 |
+
with conn.cursor() as cur:
|
| 20 |
+
cur.execute(
|
| 21 |
+
"""
|
| 22 |
+
SELECT t.id, t.transaction_date, t.description, t.transaction_type,
|
| 23 |
+
t.total_amount, a.name as account_name, c.name as category_name
|
| 24 |
+
FROM transactions t
|
| 25 |
+
JOIN transaction_entries te ON te.transaction_id = t.id
|
| 26 |
+
JOIN accounts a ON te.account_id = a.id
|
| 27 |
+
LEFT JOIN categories c ON te.category_id = c.id
|
| 28 |
+
WHERE t.id = %s
|
| 29 |
+
LIMIT 1
|
| 30 |
+
""",
|
| 31 |
+
(transaction_id,),
|
| 32 |
+
)
|
| 33 |
+
row = cur.fetchone()
|
| 34 |
+
if not row:
|
| 35 |
+
return json.dumps({"success": False, "error": f"Transaction {transaction_id} not found"})
|
| 36 |
+
|
| 37 |
+
details = {
|
| 38 |
+
"id": row[0],
|
| 39 |
+
"date": str(row[1]),
|
| 40 |
+
"description": row[2],
|
| 41 |
+
"type": row[3],
|
| 42 |
+
"amount": float(row[4]),
|
| 43 |
+
"account": row[5],
|
| 44 |
+
"category": row[6] or "Uncategorized",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
# Count entries that will be deleted
|
| 48 |
+
cur.execute(
|
| 49 |
+
"SELECT COUNT(*) FROM transaction_entries WHERE transaction_id = %s",
|
| 50 |
+
(transaction_id,),
|
| 51 |
+
)
|
| 52 |
+
entry_count = cur.fetchone()[0]
|
| 53 |
+
|
| 54 |
+
except Exception as e:
|
| 55 |
+
logger.error("[delete_transaction] Lookup error: %s", e)
|
| 56 |
+
return json.dumps({"success": False, "error": str(e)})
|
| 57 |
+
|
| 58 |
+
# --- Confirmation gate ---
|
| 59 |
+
confirmation = {
|
| 60 |
+
"action": "delete_transaction",
|
| 61 |
+
"message": f"Delete transaction #{transaction_id} and {entry_count} entries?",
|
| 62 |
+
"details": details,
|
| 63 |
+
"entries_to_delete": entry_count,
|
| 64 |
+
}
|
| 65 |
+
response = interrupt(confirmation)
|
| 66 |
+
|
| 67 |
+
if not response.get("approved"):
|
| 68 |
+
logger.info("[delete_transaction] Cancelled by user")
|
| 69 |
+
return json.dumps({"success": False, "message": "Deletion cancelled by user"})
|
| 70 |
+
|
| 71 |
+
# --- Execute the delete (CASCADE handles transaction_entries) ---
|
| 72 |
+
try:
|
| 73 |
+
with get_connection() as conn:
|
| 74 |
+
with conn.cursor() as cur:
|
| 75 |
+
cur.execute("DELETE FROM transactions WHERE id = %s", (transaction_id,))
|
| 76 |
+
if cur.rowcount == 0:
|
| 77 |
+
return json.dumps({"success": False, "error": f"Transaction {transaction_id} not found"})
|
| 78 |
+
|
| 79 |
+
logger.info("[delete_transaction] Deleted txn_id=%d (%d entries)", transaction_id, entry_count)
|
| 80 |
+
return json.dumps(
|
| 81 |
+
{
|
| 82 |
+
"success": True,
|
| 83 |
+
"transaction_id": transaction_id,
|
| 84 |
+
"message": f"Transaction #{transaction_id} deleted along with {entry_count} entries",
|
| 85 |
+
"entries_deleted": entry_count,
|
| 86 |
+
},
|
| 87 |
+
default=str,
|
| 88 |
+
)
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.error("[delete_transaction] Error: %s", e)
|
| 91 |
+
return json.dumps({"success": False, "error": str(e)})
|
src/tools/finance_query.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from langchain_core.tools import tool
|
| 4 |
+
from src.db.connection import get_connection
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("cashy.tools")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@tool
|
| 10 |
+
def finance_db_query(query: str) -> str:
|
| 11 |
+
"""Execute a SQL SELECT query on the financial database. Only SELECT queries are allowed.
|
| 12 |
+
Use this for custom queries not covered by the other tools."""
|
| 13 |
+
logger.info("[finance_db_query] SQL: %s", query[:120])
|
| 14 |
+
if not query.strip().upper().startswith("SELECT"):
|
| 15 |
+
logger.warning("[finance_db_query] Rejected non-SELECT query")
|
| 16 |
+
return json.dumps({"error": "Only SELECT queries allowed"})
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
with get_connection() as conn:
|
| 20 |
+
with conn.cursor() as cur:
|
| 21 |
+
cur.execute(query)
|
| 22 |
+
columns = [desc[0] for desc in cur.description]
|
| 23 |
+
rows = cur.fetchall()
|
| 24 |
+
results = [dict(zip(columns, row)) for row in rows]
|
| 25 |
+
logger.info("[finance_db_query] Returned %d rows", len(results))
|
| 26 |
+
return json.dumps(
|
| 27 |
+
{"success": True, "count": len(results), "data": results},
|
| 28 |
+
default=str,
|
| 29 |
+
)
|
| 30 |
+
except Exception as e:
|
| 31 |
+
logger.error("[finance_db_query] Error: %s", e)
|
| 32 |
+
return json.dumps({"error": str(e)})
|
src/tools/generate_chart.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import tempfile
|
| 4 |
+
from decimal import Decimal
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import matplotlib
|
| 8 |
+
matplotlib.use("Agg") # Non-interactive backend (no display needed)
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
import matplotlib.ticker as ticker
|
| 11 |
+
|
| 12 |
+
from langchain_core.tools import tool
|
| 13 |
+
from src.db.connection import get_connection
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger("cashy.tools")
|
| 16 |
+
|
| 17 |
+
# Consistent color palette for Cashy charts
|
| 18 |
+
COLORS = [
|
| 19 |
+
"#2196F3", # blue
|
| 20 |
+
"#4CAF50", # green
|
| 21 |
+
"#FF9800", # orange
|
| 22 |
+
"#E91E63", # pink
|
| 23 |
+
"#9C27B0", # purple
|
| 24 |
+
"#00BCD4", # cyan
|
| 25 |
+
"#FFC107", # amber
|
| 26 |
+
"#607D8B", # blue-grey
|
| 27 |
+
"#F44336", # red
|
| 28 |
+
"#8BC34A", # light green
|
| 29 |
+
"#3F51B5", # indigo
|
| 30 |
+
"#795548", # brown
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
VALID_CHART_TYPES = ("bar", "horizontal_bar", "pie", "line")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _format_currency(x, _pos):
|
| 37 |
+
"""Format axis values as $X,XXX."""
|
| 38 |
+
return f"${x:,.0f}"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _to_float(val):
|
| 42 |
+
"""Convert Decimal or other numeric types to float for matplotlib."""
|
| 43 |
+
if isinstance(val, Decimal):
|
| 44 |
+
return float(val)
|
| 45 |
+
return float(val)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@tool
|
| 49 |
+
def generate_chart(
|
| 50 |
+
chart_type: str,
|
| 51 |
+
title: str,
|
| 52 |
+
sql_query: str,
|
| 53 |
+
x_column: str,
|
| 54 |
+
y_column: str,
|
| 55 |
+
y2_column: str = "",
|
| 56 |
+
x_label: str = "",
|
| 57 |
+
y_label: str = "",
|
| 58 |
+
) -> str:
|
| 59 |
+
"""Generate a chart from SQL query results and return the image path.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
chart_type: Type of chart - "bar", "horizontal_bar", "pie", or "line"
|
| 63 |
+
title: Chart title displayed at the top
|
| 64 |
+
sql_query: SELECT query to fetch the chart data
|
| 65 |
+
x_column: Column name for x-axis (categories/labels)
|
| 66 |
+
y_column: Column name for y-axis (first series of values)
|
| 67 |
+
y2_column: Optional second column for comparison charts (e.g., budget vs actual). Creates grouped bars or a second line.
|
| 68 |
+
x_label: Optional label for x-axis
|
| 69 |
+
y_label: Optional label for y-axis
|
| 70 |
+
"""
|
| 71 |
+
logger.info("[generate_chart] type=%s, title=%s", chart_type, title)
|
| 72 |
+
logger.info("[generate_chart] SQL: %s", sql_query[:120])
|
| 73 |
+
|
| 74 |
+
# Validate chart type
|
| 75 |
+
if chart_type not in VALID_CHART_TYPES:
|
| 76 |
+
return json.dumps({
|
| 77 |
+
"success": False,
|
| 78 |
+
"error": f"Invalid chart_type '{chart_type}'. Must be one of: {', '.join(VALID_CHART_TYPES)}",
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
# Validate SQL is SELECT-only
|
| 82 |
+
if not sql_query.strip().upper().startswith("SELECT"):
|
| 83 |
+
logger.warning("[generate_chart] Rejected non-SELECT query")
|
| 84 |
+
return json.dumps({"success": False, "error": "Only SELECT queries allowed"})
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
# Execute query
|
| 88 |
+
with get_connection() as conn:
|
| 89 |
+
with conn.cursor() as cur:
|
| 90 |
+
cur.execute(sql_query)
|
| 91 |
+
columns = [desc[0] for desc in cur.description]
|
| 92 |
+
rows = cur.fetchall()
|
| 93 |
+
|
| 94 |
+
if not rows:
|
| 95 |
+
return json.dumps({"success": False, "error": "Query returned no data"})
|
| 96 |
+
|
| 97 |
+
# Validate column names exist in results
|
| 98 |
+
for col_name, col_label in [(x_column, "x_column"), (y_column, "y_column")]:
|
| 99 |
+
if col_name not in columns:
|
| 100 |
+
return json.dumps({
|
| 101 |
+
"success": False,
|
| 102 |
+
"error": f"{col_label} '{col_name}' not found. Available: {columns}",
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
has_y2 = bool(y2_column)
|
| 106 |
+
if has_y2 and y2_column not in columns:
|
| 107 |
+
return json.dumps({
|
| 108 |
+
"success": False,
|
| 109 |
+
"error": f"y2_column '{y2_column}' not found. Available: {columns}",
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
x_idx = columns.index(x_column)
|
| 113 |
+
y_idx = columns.index(y_column)
|
| 114 |
+
|
| 115 |
+
labels = [str(row[x_idx]) for row in rows]
|
| 116 |
+
values = [_to_float(row[y_idx]) for row in rows]
|
| 117 |
+
|
| 118 |
+
values2 = None
|
| 119 |
+
if has_y2:
|
| 120 |
+
y2_idx = columns.index(y2_column)
|
| 121 |
+
values2 = [_to_float(row[y2_idx]) for row in rows]
|
| 122 |
+
|
| 123 |
+
logger.info("[generate_chart] %d data points, y2=%s", len(labels), has_y2)
|
| 124 |
+
|
| 125 |
+
# Generate chart
|
| 126 |
+
fig, ax = plt.subplots(figsize=(10, 6))
|
| 127 |
+
colors = COLORS[: len(labels)]
|
| 128 |
+
|
| 129 |
+
if chart_type == "bar":
|
| 130 |
+
if has_y2:
|
| 131 |
+
# Grouped bar chart
|
| 132 |
+
x_pos = np.arange(len(labels))
|
| 133 |
+
width = 0.35
|
| 134 |
+
ax.bar(x_pos - width / 2, values, width, label=y_column.replace("_", " ").title(), color=COLORS[0])
|
| 135 |
+
ax.bar(x_pos + width / 2, values2, width, label=y2_column.replace("_", " ").title(), color=COLORS[1])
|
| 136 |
+
ax.set_xticks(x_pos)
|
| 137 |
+
ax.set_xticklabels(labels, rotation=45, ha="right")
|
| 138 |
+
ax.legend()
|
| 139 |
+
else:
|
| 140 |
+
ax.bar(labels, values, color=colors)
|
| 141 |
+
plt.xticks(rotation=45, ha="right")
|
| 142 |
+
ax.yaxis.set_major_formatter(ticker.FuncFormatter(_format_currency))
|
| 143 |
+
if x_label:
|
| 144 |
+
ax.set_xlabel(x_label)
|
| 145 |
+
if y_label:
|
| 146 |
+
ax.set_ylabel(y_label)
|
| 147 |
+
|
| 148 |
+
elif chart_type == "horizontal_bar":
|
| 149 |
+
if has_y2:
|
| 150 |
+
y_pos = np.arange(len(labels))
|
| 151 |
+
height = 0.35
|
| 152 |
+
ax.barh(y_pos - height / 2, values, height, label=y_column.replace("_", " ").title(), color=COLORS[0])
|
| 153 |
+
ax.barh(y_pos + height / 2, values2, height, label=y2_column.replace("_", " ").title(), color=COLORS[1])
|
| 154 |
+
ax.set_yticks(y_pos)
|
| 155 |
+
ax.set_yticklabels(labels)
|
| 156 |
+
ax.legend()
|
| 157 |
+
else:
|
| 158 |
+
ax.barh(labels, values, color=colors)
|
| 159 |
+
ax.xaxis.set_major_formatter(ticker.FuncFormatter(_format_currency))
|
| 160 |
+
if x_label:
|
| 161 |
+
ax.set_ylabel(x_label) # Swapped for horizontal
|
| 162 |
+
if y_label:
|
| 163 |
+
ax.set_xlabel(y_label)
|
| 164 |
+
|
| 165 |
+
elif chart_type == "pie":
|
| 166 |
+
ax.pie(
|
| 167 |
+
values,
|
| 168 |
+
labels=labels,
|
| 169 |
+
colors=colors,
|
| 170 |
+
autopct="%1.1f%%",
|
| 171 |
+
startangle=90,
|
| 172 |
+
)
|
| 173 |
+
ax.axis("equal")
|
| 174 |
+
|
| 175 |
+
elif chart_type == "line":
|
| 176 |
+
ax.plot(labels, values, color=COLORS[0], marker="o", linewidth=2, label=y_column.replace("_", " ").title() if has_y2 else None)
|
| 177 |
+
if has_y2:
|
| 178 |
+
ax.plot(labels, values2, color=COLORS[1], marker="s", linewidth=2, label=y2_column.replace("_", " ").title())
|
| 179 |
+
ax.legend()
|
| 180 |
+
ax.yaxis.set_major_formatter(ticker.FuncFormatter(_format_currency))
|
| 181 |
+
if x_label:
|
| 182 |
+
ax.set_xlabel(x_label)
|
| 183 |
+
if y_label:
|
| 184 |
+
ax.set_ylabel(y_label)
|
| 185 |
+
plt.xticks(rotation=45, ha="right")
|
| 186 |
+
|
| 187 |
+
ax.set_title(title, fontsize=14, fontweight="bold", pad=15)
|
| 188 |
+
fig.tight_layout()
|
| 189 |
+
|
| 190 |
+
# Save to temp file
|
| 191 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".png", prefix="cashy_chart_", delete=False)
|
| 192 |
+
fig.savefig(tmp.name, dpi=150, bbox_inches="tight")
|
| 193 |
+
plt.close(fig)
|
| 194 |
+
|
| 195 |
+
logger.info("[generate_chart] Saved chart to %s", tmp.name)
|
| 196 |
+
|
| 197 |
+
summary = f"{chart_type.replace('_', ' ').title()} chart with {len(labels)} data points"
|
| 198 |
+
if has_y2:
|
| 199 |
+
summary += f" comparing {y_column} vs {y2_column}"
|
| 200 |
+
|
| 201 |
+
return json.dumps({
|
| 202 |
+
"success": True,
|
| 203 |
+
"chart_path": tmp.name,
|
| 204 |
+
"chart_type": chart_type,
|
| 205 |
+
"data_points": len(labels),
|
| 206 |
+
"summary": summary,
|
| 207 |
+
})
|
| 208 |
+
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logger.error("[generate_chart] Error: %s", e)
|
| 211 |
+
plt.close("all")
|
| 212 |
+
return json.dumps({"success": False, "error": str(e)})
|
src/tools/recent_transactions.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from langchain_core.tools import tool
|
| 4 |
+
from src.db.connection import get_connection
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger("cashy.tools")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@tool
|
| 10 |
+
def get_recent_transactions(limit: int = 10) -> str:
|
| 11 |
+
"""Get the most recent transactions. Returns date, description, amount, type, category, and account.
|
| 12 |
+
Transfers are shown as a single entry with from_account and to_account."""
|
| 13 |
+
logger.info("[get_recent_transactions] limit=%d", limit)
|
| 14 |
+
try:
|
| 15 |
+
with get_connection() as conn:
|
| 16 |
+
with conn.cursor() as cur:
|
| 17 |
+
# Get recent unique transactions (by transaction id)
|
| 18 |
+
# For transfers: collapse 2 entries into one row with from/to accounts
|
| 19 |
+
cur.execute(
|
| 20 |
+
"""
|
| 21 |
+
SELECT t.id, t.transaction_date, t.description, t.transaction_type,
|
| 22 |
+
t.total_amount,
|
| 23 |
+
debit_a.name AS from_account,
|
| 24 |
+
credit_a.name AS to_account,
|
| 25 |
+
c.name AS category
|
| 26 |
+
FROM transactions t
|
| 27 |
+
LEFT JOIN transaction_entries debit_te
|
| 28 |
+
ON debit_te.transaction_id = t.id AND debit_te.entry_type = 'debit'
|
| 29 |
+
LEFT JOIN accounts debit_a
|
| 30 |
+
ON debit_te.account_id = debit_a.id
|
| 31 |
+
LEFT JOIN transaction_entries credit_te
|
| 32 |
+
ON credit_te.transaction_id = t.id AND credit_te.entry_type = 'credit'
|
| 33 |
+
LEFT JOIN accounts credit_a
|
| 34 |
+
ON credit_te.account_id = credit_a.id
|
| 35 |
+
LEFT JOIN categories c
|
| 36 |
+
ON debit_te.category_id = c.id
|
| 37 |
+
ORDER BY t.transaction_date DESC, t.id DESC
|
| 38 |
+
LIMIT %s
|
| 39 |
+
""",
|
| 40 |
+
(limit,),
|
| 41 |
+
)
|
| 42 |
+
columns = [desc[0] for desc in cur.description]
|
| 43 |
+
rows = cur.fetchall()
|
| 44 |
+
results = [dict(zip(columns, row)) for row in rows]
|
| 45 |
+
|
| 46 |
+
logger.info("[get_recent_transactions] Returned %d transactions", len(results))
|
| 47 |
+
return json.dumps(
|
| 48 |
+
{"success": True, "count": len(results), "transactions": results},
|
| 49 |
+
default=str,
|
| 50 |
+
)
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error("[get_recent_transactions] Error: %s", e)
|
| 53 |
+
return json.dumps({"success": False, "error": str(e)})
|
src/tools/spending_by_category.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from langchain_core.tools import tool
|
| 5 |
+
from src.db.connection import get_connection
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("cashy.tools")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@tool
|
| 11 |
+
def get_spending_by_category(month: int = 0, year: int = 0) -> str:
|
| 12 |
+
"""Get spending breakdown by category for a specific month/year.
|
| 13 |
+
Use month=0 and year=0 for current month."""
|
| 14 |
+
try:
|
| 15 |
+
current_month = datetime.now().month if month == 0 else month
|
| 16 |
+
current_year = datetime.now().year if year == 0 else year
|
| 17 |
+
logger.info("[get_spending_by_category] month=%d, year=%d", current_month, current_year)
|
| 18 |
+
|
| 19 |
+
with get_connection() as conn:
|
| 20 |
+
with conn.cursor() as cur:
|
| 21 |
+
cur.execute(
|
| 22 |
+
"""
|
| 23 |
+
SELECT c.name as category,
|
| 24 |
+
c.category_type,
|
| 25 |
+
SUM(te.amount) as total
|
| 26 |
+
FROM transaction_entries te
|
| 27 |
+
JOIN categories c ON te.category_id = c.id
|
| 28 |
+
JOIN transactions t ON te.transaction_id = t.id
|
| 29 |
+
WHERE EXTRACT(MONTH FROM t.transaction_date) = %s
|
| 30 |
+
AND EXTRACT(YEAR FROM t.transaction_date) = %s
|
| 31 |
+
AND te.entry_type = 'debit'
|
| 32 |
+
AND c.category_type = 'expense'
|
| 33 |
+
GROUP BY c.name, c.category_type
|
| 34 |
+
ORDER BY total DESC
|
| 35 |
+
""",
|
| 36 |
+
(current_month, current_year),
|
| 37 |
+
)
|
| 38 |
+
columns = [desc[0] for desc in cur.description]
|
| 39 |
+
rows = cur.fetchall()
|
| 40 |
+
results = [dict(zip(columns, row)) for row in rows]
|
| 41 |
+
|
| 42 |
+
logger.info("[get_spending_by_category] Returned %d categories", len(results))
|
| 43 |
+
return json.dumps(
|
| 44 |
+
{
|
| 45 |
+
"success": True,
|
| 46 |
+
"month": current_month,
|
| 47 |
+
"year": current_year,
|
| 48 |
+
"spending": results,
|
| 49 |
+
},
|
| 50 |
+
default=str,
|
| 51 |
+
)
|
| 52 |
+
except Exception as e:
|
| 53 |
+
logger.error("[get_spending_by_category] Error: %s", e)
|
| 54 |
+
return json.dumps({"success": False, "error": str(e)})
|
src/tools/update_transaction.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from langchain_core.tools import tool
|
| 5 |
+
from langgraph.types import interrupt
|
| 6 |
+
from src.db.connection import get_connection
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger("cashy.tools")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@tool
|
| 12 |
+
def update_transaction(
|
| 13 |
+
transaction_id: int,
|
| 14 |
+
description: str = "",
|
| 15 |
+
amount: float = 0.0,
|
| 16 |
+
date: str = "",
|
| 17 |
+
category_name: str = "",
|
| 18 |
+
notes: str = "",
|
| 19 |
+
) -> str:
|
| 20 |
+
"""Update an existing transaction. Only provided (non-empty/non-zero) fields are changed.
|
| 21 |
+
Requires the transaction_id. The user will be asked to confirm before changes are applied."""
|
| 22 |
+
logger.info("[update_transaction] id=%d", transaction_id)
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
with get_connection() as conn:
|
| 26 |
+
with conn.cursor() as cur:
|
| 27 |
+
# Fetch current transaction details for confirmation display
|
| 28 |
+
cur.execute(
|
| 29 |
+
"""
|
| 30 |
+
SELECT t.id, t.transaction_date, t.description, t.transaction_type,
|
| 31 |
+
t.total_amount, t.notes,
|
| 32 |
+
a.name as account_name, c.name as category_name
|
| 33 |
+
FROM transactions t
|
| 34 |
+
JOIN transaction_entries te ON te.transaction_id = t.id
|
| 35 |
+
JOIN accounts a ON te.account_id = a.id
|
| 36 |
+
LEFT JOIN categories c ON te.category_id = c.id
|
| 37 |
+
WHERE t.id = %s
|
| 38 |
+
LIMIT 1
|
| 39 |
+
""",
|
| 40 |
+
(transaction_id,),
|
| 41 |
+
)
|
| 42 |
+
row = cur.fetchone()
|
| 43 |
+
if not row:
|
| 44 |
+
return json.dumps({"success": False, "error": f"Transaction {transaction_id} not found"})
|
| 45 |
+
|
| 46 |
+
current = {
|
| 47 |
+
"id": row[0],
|
| 48 |
+
"date": str(row[1]),
|
| 49 |
+
"description": row[2],
|
| 50 |
+
"type": row[3],
|
| 51 |
+
"amount": float(row[4]),
|
| 52 |
+
"notes": row[5] or "",
|
| 53 |
+
"account": row[6],
|
| 54 |
+
"category": row[7] or "Uncategorized",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# Build changes summary
|
| 58 |
+
changes = {}
|
| 59 |
+
if description and description.strip():
|
| 60 |
+
changes["description"] = description.strip()
|
| 61 |
+
if amount > 0:
|
| 62 |
+
changes["amount"] = amount
|
| 63 |
+
if date and date.strip():
|
| 64 |
+
try:
|
| 65 |
+
datetime.strptime(date.strip(), "%Y-%m-%d")
|
| 66 |
+
changes["date"] = date.strip()
|
| 67 |
+
except ValueError:
|
| 68 |
+
return json.dumps({"success": False, "error": "Invalid date format. Use YYYY-MM-DD"})
|
| 69 |
+
if notes and notes.strip():
|
| 70 |
+
changes["notes"] = notes.strip()
|
| 71 |
+
|
| 72 |
+
# Resolve category if provided
|
| 73 |
+
new_category_id = None
|
| 74 |
+
if category_name and category_name.strip():
|
| 75 |
+
cur.execute(
|
| 76 |
+
"SELECT id, name FROM categories WHERE name ILIKE %s AND is_active = true",
|
| 77 |
+
(f"%{category_name}%",),
|
| 78 |
+
)
|
| 79 |
+
cat = cur.fetchone()
|
| 80 |
+
if cat:
|
| 81 |
+
new_category_id = cat[0]
|
| 82 |
+
changes["category"] = cat[1]
|
| 83 |
+
else:
|
| 84 |
+
return json.dumps({"success": False, "error": f"Category '{category_name}' not found"})
|
| 85 |
+
|
| 86 |
+
if not changes:
|
| 87 |
+
return json.dumps({"success": False, "error": "No fields to update"})
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.error("[update_transaction] Lookup error: %s", e)
|
| 91 |
+
return json.dumps({"success": False, "error": str(e)})
|
| 92 |
+
|
| 93 |
+
# --- Confirmation gate ---
|
| 94 |
+
confirmation = {
|
| 95 |
+
"action": "update_transaction",
|
| 96 |
+
"message": f"Update transaction #{transaction_id}?",
|
| 97 |
+
"current": current,
|
| 98 |
+
"changes": changes,
|
| 99 |
+
}
|
| 100 |
+
response = interrupt(confirmation)
|
| 101 |
+
|
| 102 |
+
if not response.get("approved"):
|
| 103 |
+
logger.info("[update_transaction] Cancelled by user")
|
| 104 |
+
return json.dumps({"success": False, "message": "Update cancelled by user"})
|
| 105 |
+
|
| 106 |
+
# --- Execute the update ---
|
| 107 |
+
try:
|
| 108 |
+
with get_connection() as conn:
|
| 109 |
+
with conn.cursor() as cur:
|
| 110 |
+
# Update transactions table
|
| 111 |
+
tx_updates = []
|
| 112 |
+
tx_params = []
|
| 113 |
+
if "description" in changes:
|
| 114 |
+
tx_updates.append("description = %s")
|
| 115 |
+
tx_params.append(changes["description"])
|
| 116 |
+
if "amount" in changes:
|
| 117 |
+
tx_updates.append("total_amount = %s")
|
| 118 |
+
tx_params.append(changes["amount"])
|
| 119 |
+
if "date" in changes:
|
| 120 |
+
tx_updates.append("transaction_date = %s")
|
| 121 |
+
tx_params.append(changes["date"])
|
| 122 |
+
if "notes" in changes:
|
| 123 |
+
tx_updates.append("notes = %s")
|
| 124 |
+
tx_params.append(changes["notes"])
|
| 125 |
+
|
| 126 |
+
if tx_updates:
|
| 127 |
+
tx_params.append(transaction_id)
|
| 128 |
+
cur.execute(
|
| 129 |
+
f"UPDATE transactions SET {', '.join(tx_updates)} WHERE id = %s",
|
| 130 |
+
tx_params,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
# Update transaction_entries if amount or category changed
|
| 134 |
+
if "amount" in changes:
|
| 135 |
+
cur.execute(
|
| 136 |
+
"UPDATE transaction_entries SET amount = %s WHERE transaction_id = %s",
|
| 137 |
+
(changes["amount"], transaction_id),
|
| 138 |
+
)
|
| 139 |
+
if new_category_id is not None:
|
| 140 |
+
cur.execute(
|
| 141 |
+
"UPDATE transaction_entries SET category_id = %s WHERE transaction_id = %s",
|
| 142 |
+
(new_category_id, transaction_id),
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
logger.info("[update_transaction] Updated txn_id=%d fields=%s", transaction_id, list(changes.keys()))
|
| 146 |
+
return json.dumps(
|
| 147 |
+
{
|
| 148 |
+
"success": True,
|
| 149 |
+
"transaction_id": transaction_id,
|
| 150 |
+
"message": f"Transaction #{transaction_id} updated",
|
| 151 |
+
"changes": changes,
|
| 152 |
+
},
|
| 153 |
+
default=str,
|
| 154 |
+
)
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error("[update_transaction] Error: %s", e)
|
| 157 |
+
return json.dumps({"success": False, "error": str(e)})
|
src/ui.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import uuid
|
| 3 |
+
import time
|
| 4 |
+
import logging
|
| 5 |
+
import gradio as gr
|
| 6 |
+
from langchain_core.messages import HumanMessage
|
| 7 |
+
from langgraph.types import Command
|
| 8 |
+
from src.config import settings
|
| 9 |
+
from src.agent.nodes import reset_model
|
| 10 |
+
from src.db.connection import get_connection
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger("cashy.ui")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def list_threads():
|
| 16 |
+
"""Get all thread_ids from the checkpoints table, most recent first."""
|
| 17 |
+
try:
|
| 18 |
+
with get_connection() as conn:
|
| 19 |
+
with conn.cursor() as cur:
|
| 20 |
+
cur.execute("""
|
| 21 |
+
SELECT thread_id, MAX(checkpoint_id) AS latest
|
| 22 |
+
FROM checkpoints
|
| 23 |
+
GROUP BY thread_id
|
| 24 |
+
ORDER BY latest DESC
|
| 25 |
+
""")
|
| 26 |
+
return [row[0] for row in cur.fetchall()]
|
| 27 |
+
except Exception as e:
|
| 28 |
+
logger.warning("Could not list threads: %s", e)
|
| 29 |
+
return []
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def load_thread_history(agent, thread_id):
|
| 33 |
+
"""Load messages from a thread and convert to Gradio chatbot format."""
|
| 34 |
+
config = {"configurable": {"thread_id": thread_id}}
|
| 35 |
+
state = agent.get_state(config)
|
| 36 |
+
messages = state.values.get("messages", [])
|
| 37 |
+
|
| 38 |
+
history = []
|
| 39 |
+
for msg in messages:
|
| 40 |
+
if msg.type == "human":
|
| 41 |
+
history.append({"role": "user", "content": msg.content})
|
| 42 |
+
elif msg.type == "ai" and msg.content:
|
| 43 |
+
history.append({"role": "assistant", "content": msg.content})
|
| 44 |
+
elif msg.type == "tool":
|
| 45 |
+
try:
|
| 46 |
+
data = json.loads(msg.content)
|
| 47 |
+
if isinstance(data, dict) and "chart_path" in data:
|
| 48 |
+
history.append({"role": "assistant", "content": {"path": data["chart_path"]}})
|
| 49 |
+
except (json.JSONDecodeError, TypeError):
|
| 50 |
+
pass
|
| 51 |
+
return history
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def get_thread_title(agent, thread_id):
|
| 55 |
+
"""Extract first user message as thread title (truncated to 50 chars)."""
|
| 56 |
+
config = {"configurable": {"thread_id": thread_id}}
|
| 57 |
+
try:
|
| 58 |
+
state = agent.get_state(config)
|
| 59 |
+
for msg in state.values.get("messages", []):
|
| 60 |
+
if msg.type == "human":
|
| 61 |
+
title = msg.content[:50]
|
| 62 |
+
return title + "..." if len(msg.content) > 50 else title
|
| 63 |
+
except Exception:
|
| 64 |
+
pass
|
| 65 |
+
return thread_id[:12]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_thread_choices(agent, min_user_messages=2):
|
| 69 |
+
"""Build dropdown choices as (title, thread_id) tuples.
|
| 70 |
+
|
| 71 |
+
Only includes threads with at least min_user_messages user messages,
|
| 72 |
+
filtering out orphan single-exchange threads.
|
| 73 |
+
"""
|
| 74 |
+
threads = list_threads()
|
| 75 |
+
choices = []
|
| 76 |
+
for tid in threads:
|
| 77 |
+
config = {"configurable": {"thread_id": tid}}
|
| 78 |
+
try:
|
| 79 |
+
state = agent.get_state(config)
|
| 80 |
+
messages = state.values.get("messages", [])
|
| 81 |
+
user_msgs = [m for m in messages if m.type == "human"]
|
| 82 |
+
if len(user_msgs) < min_user_messages:
|
| 83 |
+
continue
|
| 84 |
+
first_msg = user_msgs[0].content[:50]
|
| 85 |
+
title = first_msg + "..." if len(user_msgs[0].content) > 50 else first_msg
|
| 86 |
+
choices.append((title, tid))
|
| 87 |
+
except Exception:
|
| 88 |
+
continue
|
| 89 |
+
return choices
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def format_confirmation(interrupt_data):
|
| 93 |
+
"""Format an interrupt payload as a user-friendly confirmation message."""
|
| 94 |
+
action = interrupt_data.get("action", "unknown")
|
| 95 |
+
message = interrupt_data.get("message", "Confirm this action?")
|
| 96 |
+
|
| 97 |
+
# Build a readable action label
|
| 98 |
+
action_labels = {
|
| 99 |
+
"create_transaction": "Create Transaction",
|
| 100 |
+
"update_transaction": "Update Transaction",
|
| 101 |
+
"delete_transaction": "Delete Transaction",
|
| 102 |
+
}
|
| 103 |
+
label = action_labels.get(action, action.replace("_", " ").title())
|
| 104 |
+
|
| 105 |
+
lines = [f"**Confirm: {label}**\n"]
|
| 106 |
+
|
| 107 |
+
# Show details as a table
|
| 108 |
+
details = interrupt_data.get("details", {})
|
| 109 |
+
if details:
|
| 110 |
+
lines.append("| Field | Value |")
|
| 111 |
+
lines.append("|-------|-------|")
|
| 112 |
+
for key, value in details.items():
|
| 113 |
+
display_key = key.replace("_", " ").title()
|
| 114 |
+
if key == "amount":
|
| 115 |
+
display_value = f"${value:,.2f}"
|
| 116 |
+
else:
|
| 117 |
+
display_value = str(value)
|
| 118 |
+
lines.append(f"| {display_key} | {display_value} |")
|
| 119 |
+
lines.append("")
|
| 120 |
+
|
| 121 |
+
# Show changes for update operations
|
| 122 |
+
changes = interrupt_data.get("changes", {})
|
| 123 |
+
if changes:
|
| 124 |
+
lines.append("**Changes:**\n")
|
| 125 |
+
lines.append("| Field | New Value |")
|
| 126 |
+
lines.append("|-------|-----------|")
|
| 127 |
+
for key, value in changes.items():
|
| 128 |
+
display_key = key.replace("_", " ").title()
|
| 129 |
+
if key == "amount":
|
| 130 |
+
display_value = f"${value:,.2f}"
|
| 131 |
+
else:
|
| 132 |
+
display_value = str(value)
|
| 133 |
+
lines.append(f"| {display_key} | {display_value} |")
|
| 134 |
+
lines.append("")
|
| 135 |
+
|
| 136 |
+
# Show current values for update operations
|
| 137 |
+
current = interrupt_data.get("current", {})
|
| 138 |
+
if current:
|
| 139 |
+
lines.append("**Current values:**\n")
|
| 140 |
+
lines.append("| Field | Value |")
|
| 141 |
+
lines.append("|-------|-------|")
|
| 142 |
+
for key, value in current.items():
|
| 143 |
+
display_key = key.replace("_", " ").title()
|
| 144 |
+
if key == "amount":
|
| 145 |
+
display_value = f"${value:,.2f}"
|
| 146 |
+
else:
|
| 147 |
+
display_value = str(value)
|
| 148 |
+
lines.append(f"| {display_key} | {display_value} |")
|
| 149 |
+
lines.append("")
|
| 150 |
+
|
| 151 |
+
lines.append("Reply **yes** to confirm or **no** to cancel.")
|
| 152 |
+
|
| 153 |
+
return "\n".join(lines)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
WELCOME_MESSAGE_DEMO = """\
|
| 157 |
+
Hi! I'm **Cashy**, your AI financial advisor.
|
| 158 |
+
|
| 159 |
+
I'm connected to a demo database with **4 months of financial data** for a US-based freelance web developer:
|
| 160 |
+
|
| 161 |
+
- **11 accounts** — Chase, PayPal, Stripe, Wise, Marcus, Fidelity, credit cards, and cash
|
| 162 |
+
- **233 transactions** — client invoices, business expenses, personal spending, transfers
|
| 163 |
+
- **20 budgets** — monthly spending limits across 35 categories
|
| 164 |
+
|
| 165 |
+
**Ready to go** with the free tier, or switch to your own LLM provider in the sidebar.
|
| 166 |
+
|
| 167 |
+
Ask me anything about your finances. Here are some ideas:
|
| 168 |
+
|
| 169 |
+
1. **"What accounts do I have?"** — See all accounts and balances
|
| 170 |
+
2. **"How much did I spend this month?"** — Spending breakdown by category
|
| 171 |
+
3. **"How much did I earn from clients in January?"** — Income tracking
|
| 172 |
+
4. **"Am I over budget on anything?"** — Budget vs. actual comparison
|
| 173 |
+
5. **"Show me my last 10 transactions"** — Recent transaction history
|
| 174 |
+
"""
|
| 175 |
+
|
| 176 |
+
WELCOME_MESSAGE_PERSONAL = """\
|
| 177 |
+
Hi! I'm **Cashy**, your AI financial advisor.
|
| 178 |
+
|
| 179 |
+
I'm connected to your personal financial database. Ask me anything about your accounts, transactions, spending, or budgets.
|
| 180 |
+
|
| 181 |
+
Here are some things I can help with:
|
| 182 |
+
|
| 183 |
+
1. **"What accounts do I have?"** — See all accounts and balances
|
| 184 |
+
2. **"How much did I spend this month?"** — Spending breakdown by category
|
| 185 |
+
3. **"Show me my last 10 transactions"** — Recent transaction history
|
| 186 |
+
4. **"Am I over budget on anything?"** — Budget vs. actual comparison
|
| 187 |
+
5. **"Show me a chart of my spending"** — Visual spending analysis
|
| 188 |
+
"""
|
| 189 |
+
|
| 190 |
+
PROVIDERS = ["free-tier", "openai", "anthropic", "google", "huggingface"]
|
| 191 |
+
|
| 192 |
+
DEFAULT_MODELS = {
|
| 193 |
+
"free-tier": "Qwen/Qwen2.5-7B-Instruct",
|
| 194 |
+
"openai": "gpt-5-mini",
|
| 195 |
+
"anthropic": "claude-sonnet-4-20250514",
|
| 196 |
+
"google": "gemini-2.5-flash",
|
| 197 |
+
"huggingface": "meta-llama/Llama-3.3-70B-Instruct",
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
HF_INFERENCE_PROVIDERS = [
|
| 201 |
+
"cerebras",
|
| 202 |
+
"cohere",
|
| 203 |
+
"featherless-ai",
|
| 204 |
+
"fireworks-ai",
|
| 205 |
+
"groq",
|
| 206 |
+
"hf-inference",
|
| 207 |
+
"hyperbolic",
|
| 208 |
+
"nebius",
|
| 209 |
+
"novita",
|
| 210 |
+
"nscale",
|
| 211 |
+
"ovhcloud",
|
| 212 |
+
"sambanova",
|
| 213 |
+
"scaleway",
|
| 214 |
+
"together",
|
| 215 |
+
]
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def create_ui(agent):
|
| 219 |
+
"""Create the Gradio chat UI with compact reference sidebar."""
|
| 220 |
+
|
| 221 |
+
current_provider = settings.resolved_provider or "openai"
|
| 222 |
+
has_provider = settings.resolved_provider is not None
|
| 223 |
+
is_free = current_provider == "free-tier"
|
| 224 |
+
is_demo = settings.app_mode == "demo"
|
| 225 |
+
mode_label = "Demo" if is_demo else "Personal"
|
| 226 |
+
welcome_text = WELCOME_MESSAGE_DEMO if is_demo else WELCOME_MESSAGE_PERSONAL
|
| 227 |
+
|
| 228 |
+
FREE_TIER_DISCLAIMER = (
|
| 229 |
+
"\n\n---\n*Free tier uses a lightweight open-source model. "
|
| 230 |
+
"For better results, switch to OpenAI, Anthropic, or Google in the sidebar.*"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
def respond(message, history, thread_id, pending):
|
| 234 |
+
config = {"configurable": {"thread_id": thread_id}}
|
| 235 |
+
|
| 236 |
+
logger.info(">>> User [thread=%s]: %s", thread_id[:8], message)
|
| 237 |
+
start = time.time()
|
| 238 |
+
|
| 239 |
+
try:
|
| 240 |
+
# --- Resume from interrupt (user confirming/rejecting) ---
|
| 241 |
+
if pending:
|
| 242 |
+
approved = message.strip().lower() in ("yes", "approve", "confirm", "y")
|
| 243 |
+
logger.info("Interrupt response: %s", "approved" if approved else "rejected")
|
| 244 |
+
|
| 245 |
+
result = agent.invoke(Command(resume={"approved": approved}), config)
|
| 246 |
+
|
| 247 |
+
response = result["messages"][-1].content
|
| 248 |
+
elapsed = time.time() - start
|
| 249 |
+
logger.info("<<< Response [%.1fs]: %s", elapsed, response[:120])
|
| 250 |
+
|
| 251 |
+
if settings.resolved_provider == "free-tier":
|
| 252 |
+
response += FREE_TIER_DISCLAIMER
|
| 253 |
+
history.append({"role": "user", "content": message})
|
| 254 |
+
history.append({"role": "assistant", "content": response})
|
| 255 |
+
return "", history, thread_id, False
|
| 256 |
+
|
| 257 |
+
# --- Normal flow ---
|
| 258 |
+
# Count existing messages so we only scan new ones for charts
|
| 259 |
+
state = agent.get_state(config)
|
| 260 |
+
prev_count = len(state.values.get("messages", []))
|
| 261 |
+
|
| 262 |
+
result = agent.invoke(
|
| 263 |
+
{"messages": [HumanMessage(content=message)]},
|
| 264 |
+
config,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
# --- Check for interrupt (write operation needs confirmation) ---
|
| 268 |
+
if "__interrupt__" in result:
|
| 269 |
+
interrupt_data = result["__interrupt__"][0].value
|
| 270 |
+
confirmation_msg = format_confirmation(interrupt_data)
|
| 271 |
+
elapsed = time.time() - start
|
| 272 |
+
logger.info("<<< Interrupt [%.1fs]: %s", elapsed, interrupt_data.get("action", "unknown"))
|
| 273 |
+
|
| 274 |
+
history.append({"role": "user", "content": message})
|
| 275 |
+
history.append({"role": "assistant", "content": confirmation_msg})
|
| 276 |
+
return "", history, thread_id, True
|
| 277 |
+
|
| 278 |
+
# --- Normal response (no interrupt) ---
|
| 279 |
+
response = result["messages"][-1].content
|
| 280 |
+
elapsed = time.time() - start
|
| 281 |
+
logger.info("<<< Response [%.1fs]: %s", elapsed, response[:120])
|
| 282 |
+
|
| 283 |
+
# Scan only NEW messages for chart images (skip prior history)
|
| 284 |
+
chart_paths = []
|
| 285 |
+
for msg in result["messages"][prev_count:]:
|
| 286 |
+
if hasattr(msg, "type") and msg.type == "tool":
|
| 287 |
+
try:
|
| 288 |
+
data = json.loads(msg.content)
|
| 289 |
+
if isinstance(data, dict) and "chart_path" in data:
|
| 290 |
+
chart_paths.append(data["chart_path"])
|
| 291 |
+
except (json.JSONDecodeError, TypeError):
|
| 292 |
+
pass
|
| 293 |
+
|
| 294 |
+
if settings.resolved_provider == "free-tier":
|
| 295 |
+
response += FREE_TIER_DISCLAIMER
|
| 296 |
+
history.append({"role": "user", "content": message})
|
| 297 |
+
history.append({"role": "assistant", "content": response})
|
| 298 |
+
for path in chart_paths:
|
| 299 |
+
history.append({"role": "assistant", "content": {"path": path}})
|
| 300 |
+
|
| 301 |
+
return "", history, thread_id, False
|
| 302 |
+
|
| 303 |
+
except Exception as e:
|
| 304 |
+
logger.error("<<< Error: %s", e)
|
| 305 |
+
error_str = str(e).lower()
|
| 306 |
+
if "ssl" in error_str or "connection" in error_str and "closed" in error_str:
|
| 307 |
+
msg = (
|
| 308 |
+
"The database connection was lost (the cloud database likely went to sleep). "
|
| 309 |
+
"Please try again in a few seconds -- it should reconnect automatically. "
|
| 310 |
+
"If the issue persists, restart the Space from Settings."
|
| 311 |
+
)
|
| 312 |
+
else:
|
| 313 |
+
msg = f"**Error:** {e}"
|
| 314 |
+
history.append({"role": "user", "content": message})
|
| 315 |
+
history.append({"role": "assistant", "content": msg})
|
| 316 |
+
return "", history, thread_id, False
|
| 317 |
+
|
| 318 |
+
def switch_provider(provider):
|
| 319 |
+
settings.llm_provider = provider
|
| 320 |
+
settings.model_name = "" # reset to default for new provider
|
| 321 |
+
reset_model()
|
| 322 |
+
model = DEFAULT_MODELS.get(provider, "default")
|
| 323 |
+
is_free = provider == "free-tier"
|
| 324 |
+
is_hf = provider == "huggingface"
|
| 325 |
+
show_byok = not is_free # free-tier hides API key, model, HF provider
|
| 326 |
+
logger.info("Provider switched to: %s (%s)", provider, model)
|
| 327 |
+
if is_free:
|
| 328 |
+
status = f"Using **Free Tier** ({model}) -- no API key needed"
|
| 329 |
+
else:
|
| 330 |
+
status = f"Switched to **{provider.capitalize()}** ({model})"
|
| 331 |
+
return (
|
| 332 |
+
status,
|
| 333 |
+
gr.update(visible=show_byok, value=""),
|
| 334 |
+
gr.update(visible=show_byok, placeholder=f"Default: {model}", value=""),
|
| 335 |
+
gr.update(visible=is_hf),
|
| 336 |
+
gr.update(visible=show_byok),
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
def set_api_key(provider, api_key, model_name, hf_provider):
|
| 340 |
+
key = api_key.strip()
|
| 341 |
+
if not key:
|
| 342 |
+
return "No key entered."
|
| 343 |
+
key_fields = {
|
| 344 |
+
"openai": "openai_api_key",
|
| 345 |
+
"anthropic": "anthropic_api_key",
|
| 346 |
+
"google": "google_api_key",
|
| 347 |
+
"huggingface": "hf_token",
|
| 348 |
+
}
|
| 349 |
+
field = key_fields.get(provider)
|
| 350 |
+
if not field:
|
| 351 |
+
return f"Unknown provider: {provider}"
|
| 352 |
+
setattr(settings, field, key)
|
| 353 |
+
settings.llm_provider = provider
|
| 354 |
+
if model_name.strip():
|
| 355 |
+
settings.model_name = model_name.strip()
|
| 356 |
+
if provider == "huggingface" and hf_provider:
|
| 357 |
+
settings.hf_inference_provider = hf_provider
|
| 358 |
+
reset_model()
|
| 359 |
+
model = settings.model_name or DEFAULT_MODELS.get(provider, "default")
|
| 360 |
+
logger.info("API key set for provider: %s (%s)", provider, model)
|
| 361 |
+
return f"API key saved. Using **{provider.capitalize()}** ({model})."
|
| 362 |
+
|
| 363 |
+
def set_model(provider, model_name):
|
| 364 |
+
name = model_name.strip()
|
| 365 |
+
settings.model_name = name
|
| 366 |
+
reset_model()
|
| 367 |
+
model = name or DEFAULT_MODELS.get(provider, "default")
|
| 368 |
+
logger.info("Model changed to: %s", model)
|
| 369 |
+
return f"Model set to **{model}**."
|
| 370 |
+
|
| 371 |
+
def set_hf_provider(hf_provider):
|
| 372 |
+
settings.hf_inference_provider = hf_provider
|
| 373 |
+
reset_model()
|
| 374 |
+
logger.info("HF inference provider changed to: %s", hf_provider)
|
| 375 |
+
return f"Inference provider set to **{hf_provider}**."
|
| 376 |
+
|
| 377 |
+
welcome = [{"role": "assistant", "content": welcome_text}]
|
| 378 |
+
|
| 379 |
+
if is_demo:
|
| 380 |
+
theme = gr.themes.Glass(primary_hue="indigo")
|
| 381 |
+
else:
|
| 382 |
+
theme = gr.themes.Default()
|
| 383 |
+
|
| 384 |
+
with gr.Blocks(title="Cashy - AI Financial Advisor") as demo:
|
| 385 |
+
gr.Markdown("# Cashy — AI Financial Advisor")
|
| 386 |
+
session_thread_id = gr.State(value=lambda: str(uuid.uuid4()))
|
| 387 |
+
pending_interrupt = gr.State(value=False)
|
| 388 |
+
|
| 389 |
+
with gr.Row():
|
| 390 |
+
with gr.Column(scale=3):
|
| 391 |
+
chatbot = gr.Chatbot(
|
| 392 |
+
value=welcome,
|
| 393 |
+
height=600,
|
| 394 |
+
buttons=["copy"],
|
| 395 |
+
)
|
| 396 |
+
with gr.Row():
|
| 397 |
+
msg = gr.Textbox(
|
| 398 |
+
placeholder="Ask about your finances...",
|
| 399 |
+
show_label=False,
|
| 400 |
+
scale=9,
|
| 401 |
+
)
|
| 402 |
+
submit_btn = gr.Button("Send", variant="primary", scale=1)
|
| 403 |
+
|
| 404 |
+
with gr.Column(scale=1, min_width=250):
|
| 405 |
+
new_chat_btn = gr.Button("+ New Chat", variant="secondary")
|
| 406 |
+
|
| 407 |
+
if not is_demo:
|
| 408 |
+
with gr.Accordion("Chat History", open=False):
|
| 409 |
+
thread_dropdown = gr.Dropdown(
|
| 410 |
+
choices=[],
|
| 411 |
+
label="Previous chats",
|
| 412 |
+
interactive=True,
|
| 413 |
+
)
|
| 414 |
+
load_btn = gr.Button("Load Chat")
|
| 415 |
+
|
| 416 |
+
gr.Markdown(f"**Mode:** {mode_label}")
|
| 417 |
+
|
| 418 |
+
gr.Markdown("---")
|
| 419 |
+
|
| 420 |
+
if is_demo:
|
| 421 |
+
gr.Markdown(
|
| 422 |
+
"**Demo Data** · Oct 2025 – Jan 2026 · USD\n\n"
|
| 423 |
+
"11 accounts · 233 transactions · 20 budgets"
|
| 424 |
+
)
|
| 425 |
+
gr.Markdown("---")
|
| 426 |
+
|
| 427 |
+
gr.Markdown(
|
| 428 |
+
"**Capabilities**\n\n"
|
| 429 |
+
"- Check account balances\n"
|
| 430 |
+
"- Analyze spending by category\n"
|
| 431 |
+
"- Search transaction history\n"
|
| 432 |
+
"- Compare budgets vs. actual\n"
|
| 433 |
+
"- Create, update, delete transactions\n"
|
| 434 |
+
"- Run custom SQL queries"
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
gr.Markdown("---")
|
| 438 |
+
|
| 439 |
+
if is_demo:
|
| 440 |
+
gr.Markdown(
|
| 441 |
+
"**Try asking**\n\n"
|
| 442 |
+
'*"What\'s the balance on Chase Business?"*\n\n'
|
| 443 |
+
'*"Am I over budget on anything?"*\n\n'
|
| 444 |
+
'*"I need a $1,500 laptop -- can I afford it?"*\n\n'
|
| 445 |
+
'*"Show me a pie chart of my spending"*\n\n'
|
| 446 |
+
'*"Chart my budget vs actual for January"*'
|
| 447 |
+
)
|
| 448 |
+
else:
|
| 449 |
+
gr.Markdown(
|
| 450 |
+
"**Try asking**\n\n"
|
| 451 |
+
'*"What accounts do I have?"*\n\n'
|
| 452 |
+
'*"How much did I spend this month?"*\n\n'
|
| 453 |
+
'*"Show me a chart of my spending"*'
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
gr.Markdown("---")
|
| 457 |
+
|
| 458 |
+
provider_dropdown = gr.Dropdown(
|
| 459 |
+
choices=PROVIDERS,
|
| 460 |
+
value=current_provider,
|
| 461 |
+
label="LLM Provider",
|
| 462 |
+
)
|
| 463 |
+
with gr.Row():
|
| 464 |
+
api_key_input = gr.Textbox(
|
| 465 |
+
label="API Key",
|
| 466 |
+
placeholder="Paste your API key here...",
|
| 467 |
+
type="password",
|
| 468 |
+
scale=4,
|
| 469 |
+
visible=not is_free,
|
| 470 |
+
)
|
| 471 |
+
save_key_btn = gr.Button("Save", variant="primary", scale=1, visible=not is_free)
|
| 472 |
+
model_name_input = gr.Textbox(
|
| 473 |
+
label="Model Name (optional)",
|
| 474 |
+
placeholder=f"Default: {DEFAULT_MODELS.get(current_provider, '')}",
|
| 475 |
+
value="",
|
| 476 |
+
visible=not is_free,
|
| 477 |
+
)
|
| 478 |
+
hf_provider_dropdown = gr.Dropdown(
|
| 479 |
+
choices=HF_INFERENCE_PROVIDERS,
|
| 480 |
+
value=settings.hf_inference_provider,
|
| 481 |
+
label="Inference Provider",
|
| 482 |
+
visible=current_provider == "huggingface",
|
| 483 |
+
)
|
| 484 |
+
if is_free:
|
| 485 |
+
status_text = f"Using **Free Tier** ({DEFAULT_MODELS['free-tier']}) -- no API key needed"
|
| 486 |
+
elif has_provider:
|
| 487 |
+
status_text = f"Using **{current_provider.capitalize()}** ({DEFAULT_MODELS.get(current_provider, 'default')})"
|
| 488 |
+
else:
|
| 489 |
+
status_text = "No API key configured -- select a provider and enter one above"
|
| 490 |
+
provider_status = gr.Markdown(status_text)
|
| 491 |
+
|
| 492 |
+
# --- Event handlers ---
|
| 493 |
+
|
| 494 |
+
# Chat events (now include pending_interrupt state)
|
| 495 |
+
chat_inputs = [msg, chatbot, session_thread_id, pending_interrupt]
|
| 496 |
+
chat_outputs = [msg, chatbot, session_thread_id, pending_interrupt]
|
| 497 |
+
|
| 498 |
+
if is_demo:
|
| 499 |
+
def new_chat_demo():
|
| 500 |
+
new_id = str(uuid.uuid4())
|
| 501 |
+
logger.info("New chat started [thread=%s]", new_id[:8])
|
| 502 |
+
return new_id, welcome, "", False
|
| 503 |
+
|
| 504 |
+
msg.submit(respond, chat_inputs, chat_outputs)
|
| 505 |
+
submit_btn.click(respond, chat_inputs, chat_outputs)
|
| 506 |
+
new_chat_btn.click(
|
| 507 |
+
new_chat_demo, [], [session_thread_id, chatbot, msg, pending_interrupt]
|
| 508 |
+
)
|
| 509 |
+
else:
|
| 510 |
+
def new_chat():
|
| 511 |
+
new_id = str(uuid.uuid4())
|
| 512 |
+
choices = get_thread_choices(agent)
|
| 513 |
+
logger.info("New chat started [thread=%s]", new_id[:8])
|
| 514 |
+
return new_id, welcome, "", gr.update(choices=choices), False
|
| 515 |
+
|
| 516 |
+
def load_thread(selected_thread_id):
|
| 517 |
+
if not selected_thread_id:
|
| 518 |
+
return gr.update(), gr.update(), False
|
| 519 |
+
history = load_thread_history(agent, selected_thread_id)
|
| 520 |
+
logger.info("Loaded thread %s (%d messages)", selected_thread_id[:8], len(history))
|
| 521 |
+
return selected_thread_id, history, False
|
| 522 |
+
|
| 523 |
+
def refresh_threads():
|
| 524 |
+
choices = get_thread_choices(agent)
|
| 525 |
+
return gr.update(choices=choices)
|
| 526 |
+
|
| 527 |
+
msg.submit(respond, chat_inputs, chat_outputs).then(
|
| 528 |
+
refresh_threads, [], [thread_dropdown]
|
| 529 |
+
)
|
| 530 |
+
submit_btn.click(respond, chat_inputs, chat_outputs).then(
|
| 531 |
+
refresh_threads, [], [thread_dropdown]
|
| 532 |
+
)
|
| 533 |
+
new_chat_btn.click(
|
| 534 |
+
new_chat, [], [session_thread_id, chatbot, msg, thread_dropdown, pending_interrupt]
|
| 535 |
+
)
|
| 536 |
+
load_btn.click(
|
| 537 |
+
load_thread, [thread_dropdown], [session_thread_id, chatbot, pending_interrupt]
|
| 538 |
+
)
|
| 539 |
+
provider_dropdown.change(
|
| 540 |
+
switch_provider, [provider_dropdown],
|
| 541 |
+
[provider_status, api_key_input, model_name_input, hf_provider_dropdown, save_key_btn],
|
| 542 |
+
)
|
| 543 |
+
api_key_inputs = [provider_dropdown, api_key_input, model_name_input, hf_provider_dropdown]
|
| 544 |
+
api_key_input.submit(set_api_key, api_key_inputs, [provider_status])
|
| 545 |
+
save_key_btn.click(set_api_key, api_key_inputs, [provider_status])
|
| 546 |
+
model_name_input.submit(set_model, [provider_dropdown, model_name_input], [provider_status])
|
| 547 |
+
hf_provider_dropdown.change(set_hf_provider, [hf_provider_dropdown], [provider_status])
|
| 548 |
+
|
| 549 |
+
# Populate thread list on page load (personal mode only)
|
| 550 |
+
if not is_demo:
|
| 551 |
+
demo.load(refresh_threads, [], [thread_dropdown])
|
| 552 |
+
|
| 553 |
+
return demo, theme
|
tests/__init__.py
ADDED
|
File without changes
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|