whatsmynote 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- whatsmynote-0.1.0/LICENSE +21 -0
- whatsmynote-0.1.0/PKG-INFO +122 -0
- whatsmynote-0.1.0/README.md +94 -0
- whatsmynote-0.1.0/pyproject.toml +55 -0
- whatsmynote-0.1.0/setup.cfg +4 -0
- whatsmynote-0.1.0/whatsmynote/__init__.py +0 -0
- whatsmynote-0.1.0/whatsmynote/app/__init__.py +1 -0
- whatsmynote-0.1.0/whatsmynote/app/auth.py +150 -0
- whatsmynote-0.1.0/whatsmynote/app/chat.py +131 -0
- whatsmynote-0.1.0/whatsmynote/app/config.py +70 -0
- whatsmynote-0.1.0/whatsmynote/app/main.py +15 -0
- whatsmynote-0.1.0/whatsmynote/app/render.py +221 -0
- whatsmynote-0.1.0/whatsmynote/app/selector.py +70 -0
- whatsmynote-0.1.0/whatsmynote/app/setup.py +94 -0
- whatsmynote-0.1.0/whatsmynote.egg-info/PKG-INFO +122 -0
- whatsmynote-0.1.0/whatsmynote.egg-info/SOURCES.txt +18 -0
- whatsmynote-0.1.0/whatsmynote.egg-info/dependency_links.txt +1 -0
- whatsmynote-0.1.0/whatsmynote.egg-info/entry_points.txt +2 -0
- whatsmynote-0.1.0/whatsmynote.egg-info/requires.txt +11 -0
- whatsmynote-0.1.0/whatsmynote.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sarthak Jha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whatsmynote
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI app for managing notes and finances using LLMs.
|
|
5
|
+
Author-email: Sarthak Jha <sargedevx@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SarJ2004/WhatsMyNote
|
|
8
|
+
Project-URL: Repository, https://github.com/SarJ2004/WhatsMyNote
|
|
9
|
+
Keywords: cli,llm,finance,notes
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: python-dotenv>=0.9.9
|
|
17
|
+
Requires-Dist: inquirerpy>=0.3.4
|
|
18
|
+
Requires-Dist: plotext>=5.3.2
|
|
19
|
+
Requires-Dist: rich>=13.9.4
|
|
20
|
+
Requires-Dist: termcharts>=1.1.2
|
|
21
|
+
Requires-Dist: platformdirs>=4.2.2
|
|
22
|
+
Requires-Dist: supabase>=2.3.0
|
|
23
|
+
Requires-Dist: requests>=2.31.0
|
|
24
|
+
Requires-Dist: fastapi[standard]>=0.139.0
|
|
25
|
+
Requires-Dist: uvicorn>=0.51.0
|
|
26
|
+
Requires-Dist: twine>=6.2.0
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# WhatsMyNote
|
|
30
|
+
|
|
31
|
+
WhatsMyNote is a natural-language personal finance assistant for logging and querying lending, expenses, income, and transfers. It uses advanced **Llama 3 LLMs** to extract structured data, store it in PostgreSQL (Supabase), and answer your complex queries accurately.
|
|
32
|
+
|
|
33
|
+
## 🚀 Bring Your Own Key (BYOK) Architecture
|
|
34
|
+
WhatsMyNote now supports a secure **BYOK architecture**. You bring your own **Groq API key** to power the inference engine locally and securely.
|
|
35
|
+
|
|
36
|
+
On your first run, the app will prompt you for your `GROQ_API_KEY`. It is stored securely in a local `.env` file and is never exposed. The application uses state-of-the-art Groq models:
|
|
37
|
+
- **`llama-3.3-70b-versatile`** for reasoning, intent classification, and SQL generation.
|
|
38
|
+
- **`llama-3.1-8b-instant`** for fast and efficient data extraction.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 🛠 What It Supports & Examples
|
|
43
|
+
|
|
44
|
+
### 📉 Expenses
|
|
45
|
+
Track where your money is going.
|
|
46
|
+
- Create expense records with amount, category, merchant, payment source, item, and notes.
|
|
47
|
+
- **Example:** `I spent 320 on groceries at Walmart using HDFC`
|
|
48
|
+
- **Example:** `Update my last grocery expense amount to 400`
|
|
49
|
+
- **Example:** `Delete the Walmart expense`
|
|
50
|
+
- **Example:** `Show my highest expenses this month`
|
|
51
|
+
|
|
52
|
+
### 💰 Income
|
|
53
|
+
Record earnings, cashback, or refunds.
|
|
54
|
+
- **Example:** `I received 50000 as salary from Acme`
|
|
55
|
+
- **Example:** `Update my salary entry notes to include the July bonus`
|
|
56
|
+
- **Example:** `How much income did I receive from salary sources this year?`
|
|
57
|
+
|
|
58
|
+
### 🤝 Lending & Borrowing
|
|
59
|
+
Keep track of who owes you, and who you owe.
|
|
60
|
+
- **Example:** `I lent Sumit 500 today`
|
|
61
|
+
- **Example:** `Update Sumit's loan amount to 700`
|
|
62
|
+
- **Example:** `How much money have I lent to Sumit?`
|
|
63
|
+
|
|
64
|
+
### 🔁 Transfers
|
|
65
|
+
Record money moving between your own accounts.
|
|
66
|
+
- **Example:** `I transferred 3000 from HDFC to SBI`
|
|
67
|
+
- **Example:** `Change the transfer destination to ICICI`
|
|
68
|
+
- **Example:** `Show all transfers from HDFC this month`
|
|
69
|
+
|
|
70
|
+
### 📊 Budgets and Balances
|
|
71
|
+
Track your overall financial health.
|
|
72
|
+
- Set opening balances for your accounts (e.g., cash, wallet, SBI).
|
|
73
|
+
- Track category budgets (e.g., groceries, transport) or an `Overall` limit.
|
|
74
|
+
- **Example:** `Set opening balance for SBI to 50000`
|
|
75
|
+
- **Example:** `Set monthly budget for groceries to 10000`
|
|
76
|
+
- **Example:** `What is my current balance in SBI?`
|
|
77
|
+
- **Example:** `How much budget do I have left for groceries this month?`
|
|
78
|
+
|
|
79
|
+
### 📈 Analytics (Read-Only)
|
|
80
|
+
Run advanced queries without touching write access.
|
|
81
|
+
- Safe SQL generation with automated validation.
|
|
82
|
+
- Prefix queries with `sql:` for advanced read-only raw queries.
|
|
83
|
+
- **Example:** `What is my total spending this month?`
|
|
84
|
+
- **Example:** `Compare my income and expense totals for July`
|
|
85
|
+
- **Example:** `What is my net worth estimate?`
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## ⚙️ Environment Setup
|
|
90
|
+
|
|
91
|
+
Install dependencies and start your virtual environment:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
uv venv
|
|
95
|
+
.venv\Scripts\activate
|
|
96
|
+
uv pip install -r requirements.txt
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Set up your database connection string in your `.env` (we recommend a **Supabase PostgreSQL** instance):
|
|
100
|
+
```env
|
|
101
|
+
DATABASE_URL=postgresql+psycopg2://postgres:YOUR_PASSWORD@db.YOUR_SUPABASE_REF.supabase.co:5432/postgres
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Run the app. It will prompt you for your `GROQ_API_KEY` on the first launch!
|
|
105
|
+
```bash
|
|
106
|
+
python app/main.py
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 🏗 Tech Stack
|
|
112
|
+
|
|
113
|
+
- **Python** for core logic.
|
|
114
|
+
- **LangGraph & LangChain** for robust stateful intent routing and HITL (Human-in-the-Loop) flows.
|
|
115
|
+
- **Pydantic** for extraction validation.
|
|
116
|
+
- **SQLAlchemy & PostgreSQL** for data persistence.
|
|
117
|
+
- **Groq LLMs (Llama 3)** for lightning-fast, high-quality inference.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 🚀 Vision
|
|
122
|
+
WhatsMyNote aims to become a fully conversational financial memory system, helping you control your lending, borrowing, expenses, income, and transfers through pure natural language.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# WhatsMyNote
|
|
2
|
+
|
|
3
|
+
WhatsMyNote is a natural-language personal finance assistant for logging and querying lending, expenses, income, and transfers. It uses advanced **Llama 3 LLMs** to extract structured data, store it in PostgreSQL (Supabase), and answer your complex queries accurately.
|
|
4
|
+
|
|
5
|
+
## 🚀 Bring Your Own Key (BYOK) Architecture
|
|
6
|
+
WhatsMyNote now supports a secure **BYOK architecture**. You bring your own **Groq API key** to power the inference engine locally and securely.
|
|
7
|
+
|
|
8
|
+
On your first run, the app will prompt you for your `GROQ_API_KEY`. It is stored securely in a local `.env` file and is never exposed. The application uses state-of-the-art Groq models:
|
|
9
|
+
- **`llama-3.3-70b-versatile`** for reasoning, intent classification, and SQL generation.
|
|
10
|
+
- **`llama-3.1-8b-instant`** for fast and efficient data extraction.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 🛠 What It Supports & Examples
|
|
15
|
+
|
|
16
|
+
### 📉 Expenses
|
|
17
|
+
Track where your money is going.
|
|
18
|
+
- Create expense records with amount, category, merchant, payment source, item, and notes.
|
|
19
|
+
- **Example:** `I spent 320 on groceries at Walmart using HDFC`
|
|
20
|
+
- **Example:** `Update my last grocery expense amount to 400`
|
|
21
|
+
- **Example:** `Delete the Walmart expense`
|
|
22
|
+
- **Example:** `Show my highest expenses this month`
|
|
23
|
+
|
|
24
|
+
### 💰 Income
|
|
25
|
+
Record earnings, cashback, or refunds.
|
|
26
|
+
- **Example:** `I received 50000 as salary from Acme`
|
|
27
|
+
- **Example:** `Update my salary entry notes to include the July bonus`
|
|
28
|
+
- **Example:** `How much income did I receive from salary sources this year?`
|
|
29
|
+
|
|
30
|
+
### 🤝 Lending & Borrowing
|
|
31
|
+
Keep track of who owes you, and who you owe.
|
|
32
|
+
- **Example:** `I lent Sumit 500 today`
|
|
33
|
+
- **Example:** `Update Sumit's loan amount to 700`
|
|
34
|
+
- **Example:** `How much money have I lent to Sumit?`
|
|
35
|
+
|
|
36
|
+
### 🔁 Transfers
|
|
37
|
+
Record money moving between your own accounts.
|
|
38
|
+
- **Example:** `I transferred 3000 from HDFC to SBI`
|
|
39
|
+
- **Example:** `Change the transfer destination to ICICI`
|
|
40
|
+
- **Example:** `Show all transfers from HDFC this month`
|
|
41
|
+
|
|
42
|
+
### 📊 Budgets and Balances
|
|
43
|
+
Track your overall financial health.
|
|
44
|
+
- Set opening balances for your accounts (e.g., cash, wallet, SBI).
|
|
45
|
+
- Track category budgets (e.g., groceries, transport) or an `Overall` limit.
|
|
46
|
+
- **Example:** `Set opening balance for SBI to 50000`
|
|
47
|
+
- **Example:** `Set monthly budget for groceries to 10000`
|
|
48
|
+
- **Example:** `What is my current balance in SBI?`
|
|
49
|
+
- **Example:** `How much budget do I have left for groceries this month?`
|
|
50
|
+
|
|
51
|
+
### 📈 Analytics (Read-Only)
|
|
52
|
+
Run advanced queries without touching write access.
|
|
53
|
+
- Safe SQL generation with automated validation.
|
|
54
|
+
- Prefix queries with `sql:` for advanced read-only raw queries.
|
|
55
|
+
- **Example:** `What is my total spending this month?`
|
|
56
|
+
- **Example:** `Compare my income and expense totals for July`
|
|
57
|
+
- **Example:** `What is my net worth estimate?`
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## ⚙️ Environment Setup
|
|
62
|
+
|
|
63
|
+
Install dependencies and start your virtual environment:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv venv
|
|
67
|
+
.venv\Scripts\activate
|
|
68
|
+
uv pip install -r requirements.txt
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Set up your database connection string in your `.env` (we recommend a **Supabase PostgreSQL** instance):
|
|
72
|
+
```env
|
|
73
|
+
DATABASE_URL=postgresql+psycopg2://postgres:YOUR_PASSWORD@db.YOUR_SUPABASE_REF.supabase.co:5432/postgres
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Run the app. It will prompt you for your `GROQ_API_KEY` on the first launch!
|
|
77
|
+
```bash
|
|
78
|
+
python app/main.py
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 🏗 Tech Stack
|
|
84
|
+
|
|
85
|
+
- **Python** for core logic.
|
|
86
|
+
- **LangGraph & LangChain** for robust stateful intent routing and HITL (Human-in-the-Loop) flows.
|
|
87
|
+
- **Pydantic** for extraction validation.
|
|
88
|
+
- **SQLAlchemy & PostgreSQL** for data persistence.
|
|
89
|
+
- **Groq LLMs (Llama 3)** for lightning-fast, high-quality inference.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 🚀 Vision
|
|
94
|
+
WhatsMyNote aims to become a fully conversational financial memory system, helping you control your lending, borrowing, expenses, income, and transfers through pure natural language.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[tool.setuptools.packages.find]
|
|
6
|
+
where = ["."]
|
|
7
|
+
include = ["whatsmynote*"]
|
|
8
|
+
|
|
9
|
+
[project]
|
|
10
|
+
name = "whatsmynote"
|
|
11
|
+
version = "0.1.0"
|
|
12
|
+
description = "A CLI app for managing notes and finances using LLMs."
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
requires-python = ">=3.11"
|
|
15
|
+
authors = [
|
|
16
|
+
{ name = "Sarthak Jha", email = "sargedevx@gmail.com" }
|
|
17
|
+
]
|
|
18
|
+
license = { text = "MIT" }
|
|
19
|
+
keywords = ["cli", "llm", "finance", "notes"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
dependencies = [
|
|
27
|
+
"python-dotenv>=0.9.9",
|
|
28
|
+
"inquirerpy>=0.3.4",
|
|
29
|
+
"plotext>=5.3.2",
|
|
30
|
+
"rich>=13.9.4",
|
|
31
|
+
"termcharts>=1.1.2",
|
|
32
|
+
"platformdirs>=4.2.2",
|
|
33
|
+
"supabase>=2.3.0",
|
|
34
|
+
"requests>=2.31.0",
|
|
35
|
+
"fastapi[standard]>=0.139.0",
|
|
36
|
+
"uvicorn>=0.51.0",
|
|
37
|
+
"twine>=6.2.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/SarJ2004/WhatsMyNote"
|
|
42
|
+
Repository = "https://github.com/SarJ2004/WhatsMyNote"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
whatsmynote = "whatsmynote.app.chat:main"
|
|
46
|
+
|
|
47
|
+
[dependency-groups]
|
|
48
|
+
dev = [
|
|
49
|
+
"langgraph>=0.2.6",
|
|
50
|
+
"langchain-groq>=0.2.1",
|
|
51
|
+
"sqlalchemy>=2.0.36",
|
|
52
|
+
"psycopg2-binary>=2.9.10",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
# Trigger PyPI release for v0.1.0
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# App module — CLI application layer
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.prompt import Prompt
|
|
3
|
+
from supabase import create_client, Client
|
|
4
|
+
import os
|
|
5
|
+
import json
|
|
6
|
+
import webbrowser
|
|
7
|
+
from urllib.parse import urlparse, parse_qs
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
from whatsmynote.app.config import get_session_path, SUPABASE_URL, SUPABASE_KEY
|
|
13
|
+
SESSION_FILE = get_session_path()
|
|
14
|
+
|
|
15
|
+
def _save_session(session):
|
|
16
|
+
if session:
|
|
17
|
+
try:
|
|
18
|
+
with open(SESSION_FILE, "w") as f:
|
|
19
|
+
f.write(session.model_dump_json())
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
else:
|
|
23
|
+
if os.path.exists(SESSION_FILE):
|
|
24
|
+
try:
|
|
25
|
+
os.remove(SESSION_FILE)
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
def _load_session():
|
|
30
|
+
supabase = get_supabase()
|
|
31
|
+
if os.path.exists(SESSION_FILE):
|
|
32
|
+
try:
|
|
33
|
+
with open(SESSION_FILE, "r") as f:
|
|
34
|
+
data = json.load(f)
|
|
35
|
+
if 'access_token' in data and 'refresh_token' in data:
|
|
36
|
+
res = supabase.auth.set_session(
|
|
37
|
+
access_token=data['access_token'],
|
|
38
|
+
refresh_token=data['refresh_token']
|
|
39
|
+
)
|
|
40
|
+
return res.user
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
_supabase: Client = None
|
|
46
|
+
|
|
47
|
+
def get_supabase() -> Client:
|
|
48
|
+
global _supabase
|
|
49
|
+
if _supabase is None:
|
|
50
|
+
url = SUPABASE_URL
|
|
51
|
+
key = SUPABASE_KEY
|
|
52
|
+
if not url or not key:
|
|
53
|
+
console.print("[red]Supabase URL and Key must be set in .env[/red]")
|
|
54
|
+
exit(1)
|
|
55
|
+
_supabase = create_client(url, key)
|
|
56
|
+
return _supabase
|
|
57
|
+
|
|
58
|
+
def ensure_authenticated():
|
|
59
|
+
supabase = get_supabase()
|
|
60
|
+
|
|
61
|
+
# Try restoring previous session
|
|
62
|
+
user = _load_session()
|
|
63
|
+
if user:
|
|
64
|
+
os.environ["CURRENT_USER_ID"] = user.id
|
|
65
|
+
console.print(f"[green]Restored session for {user.email}[/green]")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
console.print("\n[bold blue]WhatsMyNote - Supabase Authentication[/bold blue]")
|
|
69
|
+
while True:
|
|
70
|
+
action = Prompt.ask("Do you want to (L)og in, (S)ign up, or (O)Auth [Google/GitHub]?", choices=["l", "s", "o", "q"], default="l").lower()
|
|
71
|
+
if action == "q":
|
|
72
|
+
exit(0)
|
|
73
|
+
|
|
74
|
+
if action == "o":
|
|
75
|
+
provider = Prompt.ask("Which provider?", choices=["google", "github"], default="google").lower()
|
|
76
|
+
|
|
77
|
+
# Setup a one-time local server to receive the callback
|
|
78
|
+
auth_code = [None]
|
|
79
|
+
|
|
80
|
+
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
81
|
+
def do_GET(self):
|
|
82
|
+
parsed_path = urlparse(self.path)
|
|
83
|
+
query_params = parse_qs(parsed_path.query)
|
|
84
|
+
|
|
85
|
+
if 'code' in query_params:
|
|
86
|
+
auth_code[0] = query_params['code'][0]
|
|
87
|
+
self.send_response(200)
|
|
88
|
+
self.send_header("Content-type", "text/html")
|
|
89
|
+
self.end_headers()
|
|
90
|
+
self.wfile.write(b"<html><body><h1>Authentication Successful!</h1><p>You can close this tab and return to the terminal.</p></body></html>")
|
|
91
|
+
else:
|
|
92
|
+
self.send_response(400)
|
|
93
|
+
self.send_header("Content-type", "text/html")
|
|
94
|
+
self.end_headers()
|
|
95
|
+
self.wfile.write(b"<html><body><h1>Authentication Failed!</h1><p>Missing auth code.</p></body></html>")
|
|
96
|
+
|
|
97
|
+
def log_message(self, format, *args):
|
|
98
|
+
pass # Silence server logs
|
|
99
|
+
|
|
100
|
+
server = HTTPServer(('localhost', 8080), OAuthCallbackHandler)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
res = supabase.auth.sign_in_with_oauth({
|
|
104
|
+
"provider": provider,
|
|
105
|
+
"options": {
|
|
106
|
+
"skip_browser_redirect": True,
|
|
107
|
+
"redirect_to": "http://localhost:8080"
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
console.print(f"[dim]Opening browser to authenticate with {provider.capitalize()}...[/dim]")
|
|
112
|
+
webbrowser.open(res.url)
|
|
113
|
+
|
|
114
|
+
# Wait for exactly one request
|
|
115
|
+
server.handle_request()
|
|
116
|
+
|
|
117
|
+
if auth_code[0]:
|
|
118
|
+
supabase.auth.exchange_code_for_session({"auth_code": auth_code[0]})
|
|
119
|
+
console.print(f"[green]Successfully authenticated via {provider.capitalize()}![/green]")
|
|
120
|
+
break
|
|
121
|
+
else:
|
|
122
|
+
console.print("[red]Authentication failed: No code received.[/red]")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
console.print(f"[red]OAuth failed: {str(e)}[/red]")
|
|
125
|
+
finally:
|
|
126
|
+
server.server_close()
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
email = Prompt.ask("Email")
|
|
130
|
+
password = Prompt.ask("Password", password=True)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
if action == "s":
|
|
134
|
+
res = supabase.auth.sign_up({"email": email, "password": password})
|
|
135
|
+
console.print("[green]Sign up successful! You can now log in.[/green]")
|
|
136
|
+
if res.user:
|
|
137
|
+
break
|
|
138
|
+
elif action == "l":
|
|
139
|
+
res = supabase.auth.sign_in_with_password({"email": email, "password": password})
|
|
140
|
+
console.print(f"[green]Logged in successfully as {res.user.email}[/green]")
|
|
141
|
+
break
|
|
142
|
+
except Exception as e:
|
|
143
|
+
console.print(f"[red]Authentication failed: {str(e)}[/red]")
|
|
144
|
+
|
|
145
|
+
# Save the user ID in global state and persist to file
|
|
146
|
+
session = supabase.auth.get_session()
|
|
147
|
+
if session and session.user:
|
|
148
|
+
_save_session(session)
|
|
149
|
+
# We store it globally so config.py can inject it into SQLAlchemy
|
|
150
|
+
os.environ["CURRENT_USER_ID"] = session.user.id
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Chat loop — the main interactive CLI with HITL support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.prompt import Prompt
|
|
8
|
+
|
|
9
|
+
from whatsmynote.app.render import render_result
|
|
10
|
+
from whatsmynote.app.setup import ensure_initial_setup
|
|
11
|
+
from whatsmynote.app.selector import display_search_results
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
# Global state to persist between turns
|
|
16
|
+
app_state = {}
|
|
17
|
+
|
|
18
|
+
def _send_state() -> dict:
|
|
19
|
+
global app_state
|
|
20
|
+
import requests
|
|
21
|
+
from whatsmynote.app.config import API_URL, get_groq_api_key
|
|
22
|
+
from whatsmynote.app.auth import get_supabase
|
|
23
|
+
|
|
24
|
+
session = get_supabase().auth.get_session()
|
|
25
|
+
token = session.access_token if session else ""
|
|
26
|
+
|
|
27
|
+
headers = {
|
|
28
|
+
"Authorization": f"Bearer {token}",
|
|
29
|
+
"X-Groq-Api-Key": get_groq_api_key() or ""
|
|
30
|
+
}
|
|
31
|
+
payload = {"message": app_state.get("raw_text", ""), "state": app_state}
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
response = requests.post(f"{API_URL}/chat", json=payload, headers=headers)
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
app_state = response.json()["state"]
|
|
37
|
+
except Exception as e:
|
|
38
|
+
console.print(f"[red]Error communicating with backend: {e}[/red]")
|
|
39
|
+
|
|
40
|
+
return app_state
|
|
41
|
+
|
|
42
|
+
def _invoke(message: str) -> dict:
|
|
43
|
+
"""Invoke the stateless graph via backend API."""
|
|
44
|
+
global app_state
|
|
45
|
+
app_state["raw_text"] = message
|
|
46
|
+
return _send_state()
|
|
47
|
+
def run_chat_loop() -> None:
|
|
48
|
+
console.print(
|
|
49
|
+
Panel.fit(
|
|
50
|
+
"[bold]WhatsMyNote CLI[/bold]\nType finance messages. Use [bold]exit[/bold] or [bold]quit[/bold] to stop.",
|
|
51
|
+
border_style="blue",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
global app_state
|
|
56
|
+
|
|
57
|
+
while True:
|
|
58
|
+
message = Prompt.ask("[bold blue]You[/bold blue]")
|
|
59
|
+
msg_lower = message.strip().lower()
|
|
60
|
+
if msg_lower in {"exit", "quit", "q"}:
|
|
61
|
+
console.print("[dim]Bye.[/dim]")
|
|
62
|
+
break
|
|
63
|
+
elif msg_lower == "/clear":
|
|
64
|
+
console.clear()
|
|
65
|
+
continue
|
|
66
|
+
elif msg_lower == "/config":
|
|
67
|
+
from whatsmynote.app.config import set_groq_api_key
|
|
68
|
+
new_key = Prompt.ask("Enter new Groq API Key", password=True)
|
|
69
|
+
if new_key:
|
|
70
|
+
set_groq_api_key(new_key.strip())
|
|
71
|
+
console.print("[green]API Key updated successfully.[/green]")
|
|
72
|
+
else:
|
|
73
|
+
console.print("[yellow]API Key update cancelled.[/yellow]")
|
|
74
|
+
continue
|
|
75
|
+
elif msg_lower == "/logout":
|
|
76
|
+
from whatsmynote.app.auth import get_supabase, SESSION_FILE
|
|
77
|
+
import os
|
|
78
|
+
try:
|
|
79
|
+
get_supabase().auth.sign_out()
|
|
80
|
+
if os.path.exists(SESSION_FILE):
|
|
81
|
+
os.remove(SESSION_FILE)
|
|
82
|
+
if "CURRENT_USER_ID" in os.environ:
|
|
83
|
+
del os.environ["CURRENT_USER_ID"]
|
|
84
|
+
console.print("[green]Logged out successfully.[/green]")
|
|
85
|
+
except Exception as e:
|
|
86
|
+
console.print(f"[red]Error logging out: {e}[/red]")
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
state = _invoke(message)
|
|
90
|
+
|
|
91
|
+
# HITL: If the system needs the user to select a record
|
|
92
|
+
if state.get("awaiting_selection") and state.get("search_results"):
|
|
93
|
+
render_result(state) # Show the "please select" message
|
|
94
|
+
|
|
95
|
+
record_type = state.get("record_type", "record")
|
|
96
|
+
intent = state.get("intent")
|
|
97
|
+
|
|
98
|
+
selected_result = display_search_results(
|
|
99
|
+
state["search_results"], record_type, multiselect=(intent == "delete")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if selected_result:
|
|
103
|
+
# Set selection and manually re-invoke
|
|
104
|
+
app_state["awaiting_selection"] = False
|
|
105
|
+
if isinstance(selected_result, list):
|
|
106
|
+
app_state["raw_text"] = f"selected multiple: {selected_result}"
|
|
107
|
+
app_state["selected_record_ids"] = selected_result
|
|
108
|
+
app_state["selected_record_id"] = None
|
|
109
|
+
else:
|
|
110
|
+
app_state["raw_text"] = f"select {selected_result}"
|
|
111
|
+
app_state["selected_record_id"] = selected_result
|
|
112
|
+
app_state["selected_record_ids"] = None
|
|
113
|
+
|
|
114
|
+
state = _send_state()
|
|
115
|
+
app_state = state
|
|
116
|
+
render_result(state)
|
|
117
|
+
else:
|
|
118
|
+
console.print("[dim]Operation cancelled.[/dim]")
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
render_result(state)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main() -> None:
|
|
125
|
+
from whatsmynote.app.config import ensure_env_config
|
|
126
|
+
ensure_env_config()
|
|
127
|
+
from whatsmynote.app.auth import ensure_authenticated
|
|
128
|
+
ensure_authenticated()
|
|
129
|
+
from whatsmynote.app.setup import ensure_initial_setup
|
|
130
|
+
ensure_initial_setup()
|
|
131
|
+
run_chat_loop()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""CLI Configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from platformdirs import user_data_dir
|
|
6
|
+
|
|
7
|
+
# --- Replace these before publishing ---
|
|
8
|
+
API_URL = "https://whatsmynote.onrender.com"
|
|
9
|
+
SUPABASE_URL = "https://emcdruetkqkrplrrxqbm.supabase.co"
|
|
10
|
+
SUPABASE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVtY2RydWV0a3FrcnBscnJ4cWJtIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODM3NjQ1NTksImV4cCI6MjA5OTM0MDU1OX0.-GRm3ZhaFGDGyy8ocDq0tAov0CxCOwCK9K9JeWPTCvY"
|
|
11
|
+
# ---------------------------------------
|
|
12
|
+
|
|
13
|
+
APP_NAME = "whatsmynote"
|
|
14
|
+
APP_AUTHOR = "whatsmynote"
|
|
15
|
+
|
|
16
|
+
def get_data_dir() -> Path:
|
|
17
|
+
"""Return the user data directory for the application."""
|
|
18
|
+
data_dir = Path(user_data_dir(APP_NAME, APP_AUTHOR))
|
|
19
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return data_dir
|
|
21
|
+
|
|
22
|
+
def get_env_path() -> Path:
|
|
23
|
+
return get_data_dir() / "config.env"
|
|
24
|
+
|
|
25
|
+
def get_session_path() -> Path:
|
|
26
|
+
"""Return the path to the Supabase session file."""
|
|
27
|
+
return get_data_dir() / "session.json"
|
|
28
|
+
|
|
29
|
+
def get_groq_api_key() -> str | None:
|
|
30
|
+
from dotenv import load_dotenv
|
|
31
|
+
load_dotenv(get_env_path())
|
|
32
|
+
return os.environ.get("GROQ_API_KEY")
|
|
33
|
+
|
|
34
|
+
def set_groq_api_key(api_key: str) -> None:
|
|
35
|
+
env_path = get_env_path()
|
|
36
|
+
content = ""
|
|
37
|
+
if env_path.exists():
|
|
38
|
+
content = env_path.read_text()
|
|
39
|
+
|
|
40
|
+
lines = [line for line in content.splitlines() if not line.startswith("GROQ_API_KEY=")]
|
|
41
|
+
lines.append(f"GROQ_API_KEY={api_key}")
|
|
42
|
+
env_path.write_text("\n".join(lines) + "\n")
|
|
43
|
+
os.environ["GROQ_API_KEY"] = api_key
|
|
44
|
+
|
|
45
|
+
def ensure_env_config() -> None:
|
|
46
|
+
from dotenv import load_dotenv
|
|
47
|
+
from rich.console import Console
|
|
48
|
+
from rich.prompt import Prompt
|
|
49
|
+
|
|
50
|
+
env_path = get_env_path()
|
|
51
|
+
console = Console()
|
|
52
|
+
|
|
53
|
+
if not env_path.exists():
|
|
54
|
+
env_path.touch(mode=0o600, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
load_dotenv(env_path)
|
|
57
|
+
|
|
58
|
+
if not os.environ.get("GROQ_API_KEY"):
|
|
59
|
+
console.print("[bold yellow]First time setup: Missing required configuration[/bold yellow]")
|
|
60
|
+
val = Prompt.ask("Enter GROQ_API_KEY", password=True)
|
|
61
|
+
if not val:
|
|
62
|
+
console.print("[red]GROQ_API_KEY is required. Exiting.[/red]")
|
|
63
|
+
exit(1)
|
|
64
|
+
|
|
65
|
+
content = env_path.read_text()
|
|
66
|
+
lines = [line for line in content.splitlines() if not line.startswith("GROQ_API_KEY=")]
|
|
67
|
+
lines.append(f"GROQ_API_KEY={val}")
|
|
68
|
+
env_path.write_text("\n".join(lines) + "\n")
|
|
69
|
+
os.environ["GROQ_API_KEY"] = val
|
|
70
|
+
console.print("[green]Configuration saved successfully.[/green]")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""WhatsMyNote CLI — single entry point."""
|
|
2
|
+
import sys
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
if sys.platform == "win32":
|
|
6
|
+
# Force UTF-8 encoding for rich/plotext on Windows legacy terminals
|
|
7
|
+
try:
|
|
8
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
9
|
+
except Exception:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
from whatsmynote.app.chat import main
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
main()
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Rich output rendering for CLI results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_item(item: Any) -> Any:
|
|
16
|
+
if hasattr(item, "__dict__"):
|
|
17
|
+
return {
|
|
18
|
+
key: value
|
|
19
|
+
for key, value in vars(item).items()
|
|
20
|
+
if not key.startswith("_") and key != "record"
|
|
21
|
+
}
|
|
22
|
+
return item
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _render_table_from_rows(rows: list[Any]) -> None:
|
|
26
|
+
if not rows:
|
|
27
|
+
console.print(Panel("No rows.", title="Result", border_style="yellow"))
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
normalized_rows = [_normalize_item(row) for row in rows]
|
|
31
|
+
first_row = normalized_rows[0]
|
|
32
|
+
if not isinstance(first_row, dict):
|
|
33
|
+
console.print(Panel(str(rows), title="Result", border_style="cyan"))
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
table = Table(title="Result", show_lines=False, header_style="bold cyan")
|
|
37
|
+
for column in first_row.keys():
|
|
38
|
+
table.add_column(str(column), overflow="fold")
|
|
39
|
+
|
|
40
|
+
for row in normalized_rows:
|
|
41
|
+
table.add_row(*[str(row.get(column, "")) for column in first_row.keys()])
|
|
42
|
+
|
|
43
|
+
console.print(table)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _render_analytics_chart(rows: list[dict[str, Any]], chart_config: dict[str, Any]) -> None | str:
|
|
47
|
+
chart_type = chart_config.get("chart_type")
|
|
48
|
+
if not chart_type or chart_type == "none":
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
x_col = chart_config.get("x_axis")
|
|
52
|
+
y_col = chart_config.get("y_axis")
|
|
53
|
+
if not x_col or not y_col:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
title = chart_config.get("title") or "Analytics Chart"
|
|
57
|
+
color = chart_config.get("color")
|
|
58
|
+
|
|
59
|
+
if chart_type in ["pie", "donut"]:
|
|
60
|
+
try:
|
|
61
|
+
import termcharts
|
|
62
|
+
data = {}
|
|
63
|
+
for row in rows:
|
|
64
|
+
k = str(row.get(x_col, ""))
|
|
65
|
+
v = float(row.get(y_col) or 0)
|
|
66
|
+
data[k] = v
|
|
67
|
+
|
|
68
|
+
if chart_type == "donut":
|
|
69
|
+
chart_obj = termcharts.donut(data, title=title)
|
|
70
|
+
else:
|
|
71
|
+
chart_obj = termcharts.pie(data, title=title)
|
|
72
|
+
|
|
73
|
+
return str(chart_obj)
|
|
74
|
+
except Exception:
|
|
75
|
+
# Fallback if termcharts fails
|
|
76
|
+
chart_type = "bar"
|
|
77
|
+
|
|
78
|
+
import plotext as plt
|
|
79
|
+
plt.clf()
|
|
80
|
+
|
|
81
|
+
if chart_type == "line":
|
|
82
|
+
plt.date_form("Y-m-d")
|
|
83
|
+
|
|
84
|
+
# Sort by date
|
|
85
|
+
sorted_rows = sorted(rows, key=lambda row: str(row.get(x_col, "")))
|
|
86
|
+
labels = [str(row.get(x_col, "")) for row in sorted_rows]
|
|
87
|
+
values = [float(row.get(y_col) or 0) for row in sorted_rows]
|
|
88
|
+
|
|
89
|
+
plt.plot(labels, values, marker="dot")
|
|
90
|
+
else:
|
|
91
|
+
# Sort by value for bar chart
|
|
92
|
+
top_rows = sorted(rows, key=lambda row: float(row.get(y_col) or 0), reverse=True)[:15]
|
|
93
|
+
labels = [str(row.get(x_col, "")) for row in top_rows]
|
|
94
|
+
values = [float(row.get(y_col) or 0) for row in top_rows]
|
|
95
|
+
|
|
96
|
+
if chart_type == "scatter":
|
|
97
|
+
plt.scatter(labels, values)
|
|
98
|
+
else:
|
|
99
|
+
plt.bar(labels, values, orientation="horizontal")
|
|
100
|
+
|
|
101
|
+
plt.title(title)
|
|
102
|
+
|
|
103
|
+
if color:
|
|
104
|
+
try:
|
|
105
|
+
if color == "diverse":
|
|
106
|
+
plt.colorless() # plotext does not strictly have diverse, but we can set multiple colors. Actually plotext uses diverse colors automatically if we don't set color.
|
|
107
|
+
else:
|
|
108
|
+
plt.color(color)
|
|
109
|
+
except Exception:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
plt.plotsize(60, 20)
|
|
113
|
+
plt.theme("clear")
|
|
114
|
+
|
|
115
|
+
# Render and encode safely to avoid Windows cp1252 crash
|
|
116
|
+
try:
|
|
117
|
+
chart_str = plt.build()
|
|
118
|
+
return chart_str
|
|
119
|
+
except Exception:
|
|
120
|
+
plt.theme("pro") # Fallback to a simpler theme
|
|
121
|
+
return plt.build()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render_budget_alerts(alerts: list[str]) -> None:
|
|
125
|
+
if not alerts:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
table = Table(title="Budget Alerts", header_style="bold yellow")
|
|
129
|
+
table.add_column("Alert")
|
|
130
|
+
for alert in alerts:
|
|
131
|
+
table.add_row(alert)
|
|
132
|
+
console.print(table)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def render_result(state: dict[str, Any]) -> None:
|
|
136
|
+
"""Render the graph output state to the CLI."""
|
|
137
|
+
if state.get("error"):
|
|
138
|
+
console.print(
|
|
139
|
+
Panel.fit(
|
|
140
|
+
f"[bold red]Error[/bold red]\n{state['error']}", border_style="red"
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
analytics_sql = state.get("analytics_sql")
|
|
146
|
+
analytics_review = state.get("analytics_review")
|
|
147
|
+
query_result = state.get("query_result")
|
|
148
|
+
answer = state.get("answer")
|
|
149
|
+
budget_alerts = state.get("budget_alerts") or []
|
|
150
|
+
|
|
151
|
+
if analytics_sql:
|
|
152
|
+
console.print(Panel.fit(analytics_sql, title="SQL", border_style="dim"))
|
|
153
|
+
|
|
154
|
+
if analytics_review:
|
|
155
|
+
console.print(
|
|
156
|
+
Panel.fit(str(analytics_review), title="SQL Review", border_style="cyan")
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if budget_alerts:
|
|
160
|
+
_render_budget_alerts(list(dict.fromkeys(budget_alerts)))
|
|
161
|
+
|
|
162
|
+
chart_config = state.get("chart_config")
|
|
163
|
+
|
|
164
|
+
if query_result is not None:
|
|
165
|
+
if isinstance(query_result, list):
|
|
166
|
+
if query_result and isinstance(query_result[0], dict) and chart_config:
|
|
167
|
+
chart = _render_analytics_chart(query_result, chart_config)
|
|
168
|
+
if chart:
|
|
169
|
+
chart_type = chart_config.get("chart_type")
|
|
170
|
+
if chart_type in ["pie", "donut"]:
|
|
171
|
+
# termcharts returns raw ANSI strings. We MUST parse them via Text.from_ansi
|
|
172
|
+
# otherwise Rich's markup parser gets confused and prints literal [31m tags.
|
|
173
|
+
from rich.text import Text
|
|
174
|
+
console.print(f"\n[bold green]Analytics Chart:[/bold green] {chart_config.get('title', '')}")
|
|
175
|
+
console.print(Text.from_ansi(chart))
|
|
176
|
+
else:
|
|
177
|
+
console.print(
|
|
178
|
+
Panel.fit(chart, title="Analytics Chart", border_style="green")
|
|
179
|
+
)
|
|
180
|
+
_render_table_from_rows(query_result)
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
if isinstance(query_result, dict):
|
|
184
|
+
table = Table(title="Result", header_style="bold cyan")
|
|
185
|
+
table.add_column("Key")
|
|
186
|
+
table.add_column("Value")
|
|
187
|
+
for key, value in query_result.items():
|
|
188
|
+
table.add_row(str(key), str(value))
|
|
189
|
+
console.print(table)
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
console.print(Panel.fit(str(query_result), title="Result", border_style="cyan"))
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
if answer:
|
|
196
|
+
console.print(Panel.fit(answer, title="Result", border_style="green"))
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# Check for successful operations
|
|
200
|
+
if state.get("saved_record_ids"):
|
|
201
|
+
count = len(state["saved_record_ids"])
|
|
202
|
+
console.print(
|
|
203
|
+
Panel.fit(f"Successfully created {count} record(s).", title="Result", border_style="green")
|
|
204
|
+
)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
if state.get("updated_record_id"):
|
|
208
|
+
console.print(
|
|
209
|
+
Panel.fit(f"Record #{state['updated_record_id']} updated successfully.", title="Result", border_style="green")
|
|
210
|
+
)
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
if state.get("deleted_record_id"):
|
|
214
|
+
console.print(
|
|
215
|
+
Panel.fit(f"Record #{state['deleted_record_id']} deleted successfully.", title="Result", border_style="green")
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
console.print(
|
|
220
|
+
Panel.fit("Operation completed.", title="Result", border_style="green")
|
|
221
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from InquirerPy import inquirer
|
|
7
|
+
from InquirerPy.base.control import Choice
|
|
8
|
+
from InquirerPy.prompts.fuzzy import FuzzyPrompt
|
|
9
|
+
|
|
10
|
+
# Monkey-patch FuzzyPrompt to prevent IndexError when toggling an empty search result
|
|
11
|
+
_original_toggle = FuzzyPrompt._handle_toggle_choice
|
|
12
|
+
def _safe_toggle(self, event) -> None:
|
|
13
|
+
try:
|
|
14
|
+
_original_toggle(self, event)
|
|
15
|
+
except IndexError:
|
|
16
|
+
pass
|
|
17
|
+
FuzzyPrompt._handle_toggle_choice = _safe_toggle
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
def display_search_results(results: list[dict[str, Any]], record_type: str, multiselect: bool = False) -> int | list[int] | None:
|
|
22
|
+
"""Display search results as a fuzzy search menu and prompt user to pick one or more.
|
|
23
|
+
|
|
24
|
+
Returns the selected record ID(s), or None if cancelled.
|
|
25
|
+
"""
|
|
26
|
+
if not results:
|
|
27
|
+
console.print("[yellow]No records found.[/yellow]")
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
choices = []
|
|
31
|
+
display_keys = [k for k in results[0].keys() if k != "id"]
|
|
32
|
+
|
|
33
|
+
for record in results:
|
|
34
|
+
# Build a searchable string out of the values
|
|
35
|
+
parts = []
|
|
36
|
+
for key in display_keys:
|
|
37
|
+
val = record.get(key)
|
|
38
|
+
if val is not None and val != "" and val != "-":
|
|
39
|
+
parts.append(f"{key.title()}: {val}")
|
|
40
|
+
|
|
41
|
+
display_str = " | ".join(parts)
|
|
42
|
+
choices.append(Choice(value=record.get("id"), name=display_str))
|
|
43
|
+
|
|
44
|
+
msg = f"Search and select a {record_type} record (Type to filter, Enter to select, Ctrl-C to cancel):"
|
|
45
|
+
if multiselect:
|
|
46
|
+
msg = f"Search and select multiple {record_type} records (Tab/Space to toggle, Ctrl-A to select all, Enter to confirm):"
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
prompt = inquirer.fuzzy(
|
|
50
|
+
message=msg,
|
|
51
|
+
choices=choices,
|
|
52
|
+
instruction="(Use arrow keys or type to fuzzy search)",
|
|
53
|
+
max_height="60%",
|
|
54
|
+
multiselect=multiselect,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if multiselect:
|
|
58
|
+
prompt._all_selected = False
|
|
59
|
+
|
|
60
|
+
@prompt.register_kb("c-a")
|
|
61
|
+
def _toggle_all(event):
|
|
62
|
+
prompt._all_selected = not getattr(prompt, "_all_selected", False)
|
|
63
|
+
prompt._handle_toggle_all(None, prompt._all_selected)
|
|
64
|
+
|
|
65
|
+
choice = prompt.execute()
|
|
66
|
+
|
|
67
|
+
return choice
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
console.print("\n[dim]Selection cancelled.[/dim]")
|
|
70
|
+
return None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""First-run setup — accounts and budgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.prompt import Confirm, IntPrompt, Prompt
|
|
9
|
+
|
|
10
|
+
from whatsmynote.app.config import API_URL, get_groq_api_key
|
|
11
|
+
from whatsmynote.app.auth import get_supabase
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
def _get_headers() -> dict:
|
|
16
|
+
session = get_supabase().auth.get_session()
|
|
17
|
+
token = session.access_token if session else ""
|
|
18
|
+
return {
|
|
19
|
+
"Authorization": f"Bearer {token}",
|
|
20
|
+
"X-Groq-Api-Key": get_groq_api_key() or ""
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def ensure_initial_setup() -> None:
|
|
24
|
+
headers = _get_headers()
|
|
25
|
+
try:
|
|
26
|
+
res = requests.get(f"{API_URL}/setup/status", headers=headers)
|
|
27
|
+
res.raise_for_status()
|
|
28
|
+
status = res.json()
|
|
29
|
+
except Exception as e:
|
|
30
|
+
console.print(f"[red]Error connecting to backend: {e}[/red]")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
account_count = status.get("account_count", 0)
|
|
34
|
+
budget_count = status.get("budget_count", 0)
|
|
35
|
+
default_account = status.get("default_account")
|
|
36
|
+
|
|
37
|
+
accounts = []
|
|
38
|
+
default_account_index = None
|
|
39
|
+
|
|
40
|
+
if account_count == 0:
|
|
41
|
+
console.print(Panel.fit("[bold]Initial account setup[/bold]\nEnter starting balances once.", border_style="cyan"))
|
|
42
|
+
count = IntPrompt.ask("How many accounts do you want to set up?", default=1)
|
|
43
|
+
for index in range(count):
|
|
44
|
+
console.print(f"[bold cyan]Account {index + 1}/{count}[/bold cyan]")
|
|
45
|
+
name = Prompt.ask("Account name")
|
|
46
|
+
opening_balance = IntPrompt.ask("Opening balance", default=0)
|
|
47
|
+
currency = Prompt.ask("Currency", default="INR")
|
|
48
|
+
notes = Prompt.ask("Notes", default="")
|
|
49
|
+
accounts.append({
|
|
50
|
+
"name": name,
|
|
51
|
+
"opening_balance": opening_balance,
|
|
52
|
+
"currency": currency,
|
|
53
|
+
"notes": notes or None,
|
|
54
|
+
})
|
|
55
|
+
if accounts:
|
|
56
|
+
default_index = IntPrompt.ask("Which account should be default?", default=1)
|
|
57
|
+
default_account_index = max(1, min(default_index, len(accounts))) - 1
|
|
58
|
+
else:
|
|
59
|
+
console.print(f"[dim]Found {account_count} account record(s). Skipping account setup.[/dim]")
|
|
60
|
+
if default_account:
|
|
61
|
+
console.print(f"[dim]Default account: {default_account}.[/dim]")
|
|
62
|
+
|
|
63
|
+
budgets = []
|
|
64
|
+
if budget_count == 0:
|
|
65
|
+
if Confirm.ask("Set up budgets now?", default=True):
|
|
66
|
+
console.print(Panel.fit("[bold]Initial budget setup[/bold]\nSet monthly limits.", border_style="magenta"))
|
|
67
|
+
count = IntPrompt.ask("How many budgets do you want to set up?", default=0)
|
|
68
|
+
for index in range(count):
|
|
69
|
+
console.print(f"[bold magenta]Budget {index + 1}/{count}[/bold magenta]")
|
|
70
|
+
category = Prompt.ask("Budget category", default="Overall")
|
|
71
|
+
amount = IntPrompt.ask("Budget limit")
|
|
72
|
+
period = Prompt.ask("Period", default="monthly")
|
|
73
|
+
notes = Prompt.ask("Notes", default="")
|
|
74
|
+
budgets.append({
|
|
75
|
+
"category": category,
|
|
76
|
+
"amount": amount,
|
|
77
|
+
"period": period,
|
|
78
|
+
"notes": notes or None,
|
|
79
|
+
})
|
|
80
|
+
else:
|
|
81
|
+
console.print(f"[dim]Found {budget_count} budget record(s). Skipping budget setup.[/dim]")
|
|
82
|
+
|
|
83
|
+
if accounts or budgets:
|
|
84
|
+
payload = {
|
|
85
|
+
"accounts": accounts,
|
|
86
|
+
"budgets": budgets,
|
|
87
|
+
"default_account_index": default_account_index
|
|
88
|
+
}
|
|
89
|
+
try:
|
|
90
|
+
res = requests.post(f"{API_URL}/setup", json=payload, headers=headers)
|
|
91
|
+
res.raise_for_status()
|
|
92
|
+
console.print("[green]Setup completed successfully.[/green]")
|
|
93
|
+
except Exception as e:
|
|
94
|
+
console.print(f"[red]Error saving setup to backend: {e}[/red]")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whatsmynote
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI app for managing notes and finances using LLMs.
|
|
5
|
+
Author-email: Sarthak Jha <sargedevx@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SarJ2004/WhatsMyNote
|
|
8
|
+
Project-URL: Repository, https://github.com/SarJ2004/WhatsMyNote
|
|
9
|
+
Keywords: cli,llm,finance,notes
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: python-dotenv>=0.9.9
|
|
17
|
+
Requires-Dist: inquirerpy>=0.3.4
|
|
18
|
+
Requires-Dist: plotext>=5.3.2
|
|
19
|
+
Requires-Dist: rich>=13.9.4
|
|
20
|
+
Requires-Dist: termcharts>=1.1.2
|
|
21
|
+
Requires-Dist: platformdirs>=4.2.2
|
|
22
|
+
Requires-Dist: supabase>=2.3.0
|
|
23
|
+
Requires-Dist: requests>=2.31.0
|
|
24
|
+
Requires-Dist: fastapi[standard]>=0.139.0
|
|
25
|
+
Requires-Dist: uvicorn>=0.51.0
|
|
26
|
+
Requires-Dist: twine>=6.2.0
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# WhatsMyNote
|
|
30
|
+
|
|
31
|
+
WhatsMyNote is a natural-language personal finance assistant for logging and querying lending, expenses, income, and transfers. It uses advanced **Llama 3 LLMs** to extract structured data, store it in PostgreSQL (Supabase), and answer your complex queries accurately.
|
|
32
|
+
|
|
33
|
+
## 🚀 Bring Your Own Key (BYOK) Architecture
|
|
34
|
+
WhatsMyNote now supports a secure **BYOK architecture**. You bring your own **Groq API key** to power the inference engine locally and securely.
|
|
35
|
+
|
|
36
|
+
On your first run, the app will prompt you for your `GROQ_API_KEY`. It is stored securely in a local `.env` file and is never exposed. The application uses state-of-the-art Groq models:
|
|
37
|
+
- **`llama-3.3-70b-versatile`** for reasoning, intent classification, and SQL generation.
|
|
38
|
+
- **`llama-3.1-8b-instant`** for fast and efficient data extraction.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 🛠 What It Supports & Examples
|
|
43
|
+
|
|
44
|
+
### 📉 Expenses
|
|
45
|
+
Track where your money is going.
|
|
46
|
+
- Create expense records with amount, category, merchant, payment source, item, and notes.
|
|
47
|
+
- **Example:** `I spent 320 on groceries at Walmart using HDFC`
|
|
48
|
+
- **Example:** `Update my last grocery expense amount to 400`
|
|
49
|
+
- **Example:** `Delete the Walmart expense`
|
|
50
|
+
- **Example:** `Show my highest expenses this month`
|
|
51
|
+
|
|
52
|
+
### 💰 Income
|
|
53
|
+
Record earnings, cashback, or refunds.
|
|
54
|
+
- **Example:** `I received 50000 as salary from Acme`
|
|
55
|
+
- **Example:** `Update my salary entry notes to include the July bonus`
|
|
56
|
+
- **Example:** `How much income did I receive from salary sources this year?`
|
|
57
|
+
|
|
58
|
+
### 🤝 Lending & Borrowing
|
|
59
|
+
Keep track of who owes you, and who you owe.
|
|
60
|
+
- **Example:** `I lent Sumit 500 today`
|
|
61
|
+
- **Example:** `Update Sumit's loan amount to 700`
|
|
62
|
+
- **Example:** `How much money have I lent to Sumit?`
|
|
63
|
+
|
|
64
|
+
### 🔁 Transfers
|
|
65
|
+
Record money moving between your own accounts.
|
|
66
|
+
- **Example:** `I transferred 3000 from HDFC to SBI`
|
|
67
|
+
- **Example:** `Change the transfer destination to ICICI`
|
|
68
|
+
- **Example:** `Show all transfers from HDFC this month`
|
|
69
|
+
|
|
70
|
+
### 📊 Budgets and Balances
|
|
71
|
+
Track your overall financial health.
|
|
72
|
+
- Set opening balances for your accounts (e.g., cash, wallet, SBI).
|
|
73
|
+
- Track category budgets (e.g., groceries, transport) or an `Overall` limit.
|
|
74
|
+
- **Example:** `Set opening balance for SBI to 50000`
|
|
75
|
+
- **Example:** `Set monthly budget for groceries to 10000`
|
|
76
|
+
- **Example:** `What is my current balance in SBI?`
|
|
77
|
+
- **Example:** `How much budget do I have left for groceries this month?`
|
|
78
|
+
|
|
79
|
+
### 📈 Analytics (Read-Only)
|
|
80
|
+
Run advanced queries without touching write access.
|
|
81
|
+
- Safe SQL generation with automated validation.
|
|
82
|
+
- Prefix queries with `sql:` for advanced read-only raw queries.
|
|
83
|
+
- **Example:** `What is my total spending this month?`
|
|
84
|
+
- **Example:** `Compare my income and expense totals for July`
|
|
85
|
+
- **Example:** `What is my net worth estimate?`
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## ⚙️ Environment Setup
|
|
90
|
+
|
|
91
|
+
Install dependencies and start your virtual environment:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
uv venv
|
|
95
|
+
.venv\Scripts\activate
|
|
96
|
+
uv pip install -r requirements.txt
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Set up your database connection string in your `.env` (we recommend a **Supabase PostgreSQL** instance):
|
|
100
|
+
```env
|
|
101
|
+
DATABASE_URL=postgresql+psycopg2://postgres:YOUR_PASSWORD@db.YOUR_SUPABASE_REF.supabase.co:5432/postgres
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Run the app. It will prompt you for your `GROQ_API_KEY` on the first launch!
|
|
105
|
+
```bash
|
|
106
|
+
python app/main.py
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 🏗 Tech Stack
|
|
112
|
+
|
|
113
|
+
- **Python** for core logic.
|
|
114
|
+
- **LangGraph & LangChain** for robust stateful intent routing and HITL (Human-in-the-Loop) flows.
|
|
115
|
+
- **Pydantic** for extraction validation.
|
|
116
|
+
- **SQLAlchemy & PostgreSQL** for data persistence.
|
|
117
|
+
- **Groq LLMs (Llama 3)** for lightning-fast, high-quality inference.
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 🚀 Vision
|
|
122
|
+
WhatsMyNote aims to become a fully conversational financial memory system, helping you control your lending, borrowing, expenses, income, and transfers through pure natural language.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
whatsmynote/__init__.py
|
|
5
|
+
whatsmynote.egg-info/PKG-INFO
|
|
6
|
+
whatsmynote.egg-info/SOURCES.txt
|
|
7
|
+
whatsmynote.egg-info/dependency_links.txt
|
|
8
|
+
whatsmynote.egg-info/entry_points.txt
|
|
9
|
+
whatsmynote.egg-info/requires.txt
|
|
10
|
+
whatsmynote.egg-info/top_level.txt
|
|
11
|
+
whatsmynote/app/__init__.py
|
|
12
|
+
whatsmynote/app/auth.py
|
|
13
|
+
whatsmynote/app/chat.py
|
|
14
|
+
whatsmynote/app/config.py
|
|
15
|
+
whatsmynote/app/main.py
|
|
16
|
+
whatsmynote/app/render.py
|
|
17
|
+
whatsmynote/app/selector.py
|
|
18
|
+
whatsmynote/app/setup.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
whatsmynote
|