GitHub Actions commited on
Commit
17a78b5
·
0 Parent(s):

Deploy to HF Spaces

Browse files
.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
+ ![Cashy Demo](docs/demo.gif)
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