├── .gitignore ├── LICENCE.txt ├── README.MD ├── config.yaml ├── data └── walmart-bon.jpeg ├── documents ├── api_documentation.png ├── invoice_extraction_in_action.png ├── langgraph.png ├── openapi.json └── pg_admin_screenshot.png ├── env.example ├── requirements.txt └── src ├── api ├── __init__.py ├── category_routes.py ├── expenses_routes.py ├── payment_methods_routes.py └── run_api.py ├── chain ├── __init__.py ├── graphstate.py ├── helpers │ ├── __init__.py │ └── get_payment_methods_and_categories.py └── nodes │ ├── __init__.py │ ├── categorizer.py │ ├── correct.py │ ├── db_entry.py │ ├── humancheck.py │ ├── imageencoder.py │ └── jsonparser.py └── database ├── __init__.py ├── create_categories_and_payment_methods.py ├── create_tables.py └── db_connection.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore environment files 2 | .env 3 | 4 | # Ignore the data directory 5 | # /data 6 | 7 | # drawingboard 8 | documents/link_to_bord.txt 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | .DS_Store 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | *.py,cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | cover/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # PyBuilder 86 | .pybuilder/ 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # IPython 93 | profile_default/ 94 | ipython_config.py 95 | 96 | # pyenv 97 | # For a library or package, you might want to ignore these files since the code is 98 | # intended to run in multiple environments; otherwise, check them in: 99 | # .python-version 100 | 101 | # pipenv 102 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 103 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 104 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 105 | # install all needed dependencies. 106 | #Pipfile.lock 107 | 108 | # poetry 109 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 110 | # This is especially recommended for binary packages to ensure reproducibility, and is more 111 | # commonly ignored for libraries. 112 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 113 | #poetry.lock 114 | 115 | # pdm 116 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 117 | #pdm.lock 118 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 119 | # in version control. 120 | # https://pdm.fming.dev/#use-with-ide 121 | .pdm.toml 122 | 123 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 124 | __pypackages__/ 125 | 126 | # Celery stuff 127 | celerybeat-schedule 128 | celerybeat.pid 129 | 130 | # SageMath parsed files 131 | *.sage.py 132 | 133 | # Environments 134 | .env 135 | .venv 136 | env/ 137 | venv/ 138 | ENV/ 139 | env.bak/ 140 | venv.bak/ 141 | 142 | # Spyder project settings 143 | .spyderproject 144 | .spyproject 145 | 146 | # Rope project settings 147 | .ropeproject 148 | 149 | # mkdocs documentation 150 | /site 151 | 152 | # mypy 153 | .mypy_cache/ 154 | .dmypy.json 155 | dmypy.json 156 | 157 | # Pyre type checker 158 | .pyre/ 159 | 160 | # pytype static type analyzer 161 | .pytype/ 162 | 163 | # Cython debug symbols 164 | cython_debug/ 165 | 166 | # PyCharm 167 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 168 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 169 | # and can be added to the global gitignore or merged into this file. For a more nuclear 170 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 171 | #.idea/ -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Jan Willem Altink 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. 22 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # LangGraph Expense tracker 2 | 3 | ## In short 4 | 5 | Small project exploring the possibilitys of LangGraph. 6 | It lets you sent pictures of invoices, it structures and categorize the expenses and puts them in a database. 7 | 8 | ### Visual overview of the project 9 | 10 | ![Visual overview of the project](documents/langgraph.png) 11 | To zoom visit the whiteboard [here](https://link.excalidraw.com/l/5NC0r7Sejhe/39ULXmBwigA). 12 | 13 | ### Some invoice extraction in action 14 | 15 | ![input-output-example](documents/invoice_extraction_in_action.png) 16 | 17 | ## Project structure 18 | 19 | ```shell 20 | . 21 | ├── LICENCE.txt 22 | ├── README.MD 23 | ├── config.yaml 24 | ├── data 25 | │   └── walmart-bon.jpeg 26 | ├── documents 27 | │   ├── api_documentation.png 28 | │   ├── langgraph.png 29 | │   ├── openapi.json 30 | │   └── pg_admin_screenshot.png 31 | ├── env.example 32 | ├── requirements.txt 33 | └── src 34 | ├── api 35 | │   ├── __init__.py 36 | │   ├── category_routes.py 37 | │   ├── expenses_routes.py 38 | │   ├── payment_methods_routes.py 39 | │   └── run_api.py 40 | ├── chain 41 | │   ├── __init__.py 42 | │   ├── graphstate.py 43 | │   ├── helpers 44 | │   │   ├── __init__.py 45 | │   │   └── get_payment_methods_and_categories.py 46 | │   └── nodes 47 | │   ├── __init__.py 48 | │   ├── categorizer.py 49 | │   ├── correct.py 50 | │   ├── db_entry.py 51 | │   ├── humancheck.py 52 | │   ├── imageencoder.py 53 | │   └── jsonparser.py 54 | └── database 55 | ├── __init__.py 56 | ├── create_categories_and_payment_methods.py 57 | ├── create_tables.py 58 | └── db_connection.py 59 | 60 | 9 directories, 30 files 61 | ``` 62 | 63 | ## Step by step 64 | 65 | ### 1. Create Project 66 | 67 | **1.1 Create virtual environment**\ 68 | Using Conda, venv or any other tool of your liking. 69 | 70 | **1.2 activate virtual environment** 71 | 72 | **1.3 clone repo**\ 73 | !TO DO! 74 | 75 | **1.4 install requirements**\ 76 | !TO DO! 77 | 78 | **1.5 create .env file**\ 79 | See example [here](.env.example). 80 | 81 | --- 82 | 83 | ### 2. Set up the database 84 | 85 | #### 2.1 Prerequisites 86 | 87 | **2.1.1 Install postgresql:** 88 | ```shell 89 | brew install postgresql 90 | ``` 91 | *(other ways to [install Postgresql](https://www.postgresql.org/ "Postgresql Homepage"))* 92 | 93 | **2.1.2 Install Docker:** 94 | ```shell 95 | brew install docker 96 | ``` 97 | *(other ways to [install Docker](https://www.docker.com// "Docker Homepage"))* 98 | 99 | #### 2.2 Make a Docker container 100 | 101 | **2.2.1 Create:** 102 | ```shell 103 | docker run -d \ 104 | --name postgres-expenses \ 105 | -e POSTGRES_USER=expenses \ 106 | -e POSTGRES_PASSWORD=money$ \ 107 | -e POSTGRES_DB=expenses \ 108 | -p 6025:5432 \ 109 | postgres:latest 110 | ``` 111 | **2.2.2 Control:**\ 112 | Use the following command to see if the container is running correctly: 113 | ```shell 114 | docker ps 115 | ``` 116 | it should show a list of running containers. 117 | 118 | #### 2.3 Configure database 119 | 120 | **2.3.1 Create tables**\ 121 | Add tables for our expense tracking by running the `/src/database/create_tables.py` script *([link](src/database/create_tables.py))* 122 | 123 | **2.3.2 Inspect tables**\ 124 | Using a tool link [PGAdmin](pgadmin.org), you can inspect if the tables in the database are all there. 125 | 126 | ![screenshot of PGAdmin overview of tables](documents/pg_admin_screenshot.png) 127 | 128 | ### 3. set up API 129 | 130 | -Go to the root folder of your project and activate virtual environment 131 | ```shell 132 | CD path/to/your/projectfolder 133 | workon expense-tracker 134 | ``` 135 | *i have some shell aliases set up, the [workon] command should probably be something like [conda activate] or [source [env]]* 136 | 137 | -activate virtual environment 138 | ```shell 139 | (expense_tracker) 140 | ~/Developer/expense_tracker 141 | ▶ uvicorn src.api.run_api:app --reload 142 | INFO: Will watch for changes in these directories: ['/Users/jw/developer/expense_tracker'] 143 | INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) 144 | INFO: Started reloader process [12588] using StatReload 145 | INFO: Started server process [12590] 146 | INFO: Waiting for application startup. 147 | INFO: Application startup complete. 148 | ``` 149 | 150 | You can visit [http://localhost:8000/docs#/](http://localhost:8000/docs#/) for a page with documentation about the API: 151 | 152 | ![screenshot of documents page](documents/api_documentation.png) 153 | 154 | ## Database 155 | 156 | The database consists of three main tables: `categories`, `payment_methods`, and `expenses`. 157 | 158 | ### Table: categories 159 | This table contains a list of categories for expenses. Each category has a unique ID and a name. 160 | 161 | - **Columns**: 162 | - `category_id` (SERIAL, Primary Key): The unique ID for the category. 163 | - `category_name` (VARCHAR(100), Unique): The name of the category. 164 | 165 | ### Table: payment_methods 166 | This table contains various payment methods that can be used for expenses. 167 | 168 | - **Columns**: 169 | - `payment_method_id` (SERIAL, Primary Key): The unique ID for the payment method. 170 | - `payment_method_name` (VARCHAR(50), Unique): The name of the payment method. 171 | 172 | ### Table: expenses 173 | This is the main table for tracking expenses. It contains information such as the date, the category (with a reference to the `categories` table), the payment method (with a reference to the `payment_methods` table), the amount, VAT, and other details. 174 | 175 | - **Columns**: 176 | - `transaction_id` (SERIAL, Primary Key): The unique ID for the transaction. 177 | - `date` (DATE): The date of the expense. 178 | - `category_id` (INTEGER, Foreign Key): Reference to the `categories` table. 179 | - `description` (TEXT): A short description of the expense. 180 | - `amount` (DECIMAL(10, 2)): The amount of the expense. 181 | - `vat` (DECIMAL(10, 2)): The VAT for the expense. 182 | - `payment_method_id` (INTEGER, Foreign Key): Reference to the `payment_methods` table. 183 | - `business_personal` (VARCHAR(50)): Indicates whether the expense is business or personal. 184 | - `declared_on` (DATE): The date when the expense was declared. 185 | 186 | ## API 187 | See API documentation here: [openapi.json](documents/openapi.json) 188 | 189 | ## LangChain 190 | the way this chain works is best described by showing the LangSmith Trace: 191 | [click here to have a look](https://smith.langchain.com/public/6aed5b64-0a5d-41ce-946b-5e5ad6cbf227/r) 192 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | # Here you can configure your initial list of categories and payment methods 2 | categories: 3 | - Housing 4 | - Transportation 5 | - Food 6 | - Healthcare 7 | - Entertainment 8 | - Education 9 | - Personal Care 10 | - Travel 11 | - Insurance 12 | - Utilities 13 | - Technology 14 | - Telecommunications 15 | - Subscriptions 16 | - Office Supplies 17 | - Legal Services 18 | - Marketing 19 | - Accounting and Finance 20 | - Human Resources 21 | - Consulting 22 | - Training and Development 23 | - Professional Services 24 | - Maintenance and Repairs 25 | - Rentals and Leasing 26 | - Taxes 27 | - Charitable Contributions 28 | - Investments 29 | - Gifts 30 | - Miscellaneous 31 | - Business Operations 32 | - Research and Development 33 | 34 | payment_methods: 35 | - Credit Card 36 | - Debit Card 37 | - Bank Transfer 38 | - Cash 39 | - Other 40 | -------------------------------------------------------------------------------- /data/walmart-bon.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwa91/LangGraph-Expense-Tracker/4b56d442ccd53016f338ff477a6ef0f9ac03c64e/data/walmart-bon.jpeg -------------------------------------------------------------------------------- /documents/api_documentation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwa91/LangGraph-Expense-Tracker/4b56d442ccd53016f338ff477a6ef0f9ac03c64e/documents/api_documentation.png -------------------------------------------------------------------------------- /documents/invoice_extraction_in_action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwa91/LangGraph-Expense-Tracker/4b56d442ccd53016f338ff477a6ef0f9ac03c64e/documents/invoice_extraction_in_action.png -------------------------------------------------------------------------------- /documents/langgraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwa91/LangGraph-Expense-Tracker/4b56d442ccd53016f338ff477a6ef0f9ac03c64e/documents/langgraph.png -------------------------------------------------------------------------------- /documents/openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.1.0", 3 | "info": { 4 | "title": "Expense Management API", 5 | "version": "1.0.0" 6 | }, 7 | "paths": { 8 | "/categories": { 9 | "get": { 10 | "summary": "Get Categories", 11 | "operationId": "get_categories_categories_get", 12 | "responses": { 13 | "200": { 14 | "description": "Successful Response", 15 | "content": { 16 | "application/json": { 17 | "schema": { 18 | 19 | } 20 | } 21 | } 22 | } 23 | } 24 | }, 25 | "post": { 26 | "summary": "Create Category", 27 | "operationId": "create_category_categories_post", 28 | "requestBody": { 29 | "required": true, 30 | "content": { 31 | "application/json": { 32 | "schema": { 33 | "$ref": "#/components/schemas/CategoryCreate" 34 | } 35 | } 36 | } 37 | }, 38 | "responses": { 39 | "201": { 40 | "description": "Successful Response", 41 | "content": { 42 | "application/json": { 43 | "schema": { 44 | 45 | } 46 | } 47 | } 48 | }, 49 | "422": { 50 | "description": "Validation Error", 51 | "content": { 52 | "application/json": { 53 | "schema": { 54 | "$ref": "#/components/schemas/HTTPValidationError" 55 | } 56 | } 57 | } 58 | } 59 | } 60 | }, 61 | "delete": { 62 | "summary": "Delete Category", 63 | "operationId": "delete_category_categories_delete", 64 | "parameters": [ 65 | { 66 | "name": "category_id", 67 | "in": "query", 68 | "required": true, 69 | "schema": { 70 | "type": "integer", 71 | "title": "Category Id" 72 | } 73 | } 74 | ], 75 | "responses": { 76 | "200": { 77 | "description": "Successful Response", 78 | "content": { 79 | "application/json": { 80 | "schema": { 81 | 82 | } 83 | } 84 | } 85 | }, 86 | "422": { 87 | "description": "Validation Error", 88 | "content": { 89 | "application/json": { 90 | "schema": { 91 | "$ref": "#/components/schemas/HTTPValidationError" 92 | } 93 | } 94 | } 95 | } 96 | } 97 | } 98 | }, 99 | "/payment_methods": { 100 | "get": { 101 | "summary": "Get Payment Methods", 102 | "operationId": "get_payment_methods_payment_methods_get", 103 | "responses": { 104 | "200": { 105 | "description": "Successful Response", 106 | "content": { 107 | "application/json": { 108 | "schema": { 109 | 110 | } 111 | } 112 | } 113 | } 114 | } 115 | }, 116 | "post": { 117 | "summary": "Create Payment Method", 118 | "operationId": "create_payment_method_payment_methods_post", 119 | "requestBody": { 120 | "required": true, 121 | "content": { 122 | "application/json": { 123 | "schema": { 124 | "$ref": "#/components/schemas/PaymentMethodCreate" 125 | } 126 | } 127 | } 128 | }, 129 | "responses": { 130 | "201": { 131 | "description": "Successful Response", 132 | "content": { 133 | "application/json": { 134 | "schema": { 135 | 136 | } 137 | } 138 | } 139 | }, 140 | "422": { 141 | "description": "Validation Error", 142 | "content": { 143 | "application/json": { 144 | "schema": { 145 | "$ref": "#/components/schemas/HTTPValidationError" 146 | } 147 | } 148 | } 149 | } 150 | } 151 | }, 152 | "delete": { 153 | "summary": "Delete Payment Method", 154 | "operationId": "delete_payment_method_payment_methods_delete", 155 | "parameters": [ 156 | { 157 | "name": "payment_method_id", 158 | "in": "query", 159 | "required": true, 160 | "schema": { 161 | "type": "integer", 162 | "title": "Payment Method Id" 163 | } 164 | } 165 | ], 166 | "responses": { 167 | "200": { 168 | "description": "Successful Response", 169 | "content": { 170 | "application/json": { 171 | "schema": { 172 | 173 | } 174 | } 175 | } 176 | }, 177 | "422": { 178 | "description": "Validation Error", 179 | "content": { 180 | "application/json": { 181 | "schema": { 182 | "$ref": "#/components/schemas/HTTPValidationError" 183 | } 184 | } 185 | } 186 | } 187 | } 188 | } 189 | }, 190 | "/expenses": { 191 | "get": { 192 | "summary": "Get Expenses", 193 | "operationId": "get_expenses_expenses_get", 194 | "parameters": [ 195 | { 196 | "name": "category_id", 197 | "in": "query", 198 | "required": false, 199 | "schema": { 200 | "anyOf": [ 201 | { 202 | "type": "integer" 203 | }, 204 | { 205 | "type": "null" 206 | } 207 | ], 208 | "description": "Filter by category ID", 209 | "title": "Category Id" 210 | }, 211 | "description": "Filter by category ID" 212 | }, 213 | { 214 | "name": "payment_method_id", 215 | "in": "query", 216 | "required": false, 217 | "schema": { 218 | "anyOf": [ 219 | { 220 | "type": "integer" 221 | }, 222 | { 223 | "type": "null" 224 | } 225 | ], 226 | "description": "Filter by payment method ID", 227 | "title": "Payment Method Id" 228 | }, 229 | "description": "Filter by payment method ID" 230 | }, 231 | { 232 | "name": "start_date", 233 | "in": "query", 234 | "required": false, 235 | "schema": { 236 | "anyOf": [ 237 | { 238 | "type": "string", 239 | "format": "date" 240 | }, 241 | { 242 | "type": "null" 243 | } 244 | ], 245 | "description": "Filter by start date", 246 | "title": "Start Date" 247 | }, 248 | "description": "Filter by start date" 249 | }, 250 | { 251 | "name": "end_date", 252 | "in": "query", 253 | "required": false, 254 | "schema": { 255 | "anyOf": [ 256 | { 257 | "type": "string", 258 | "format": "date" 259 | }, 260 | { 261 | "type": "null" 262 | } 263 | ], 264 | "description": "Filter by end date", 265 | "title": "End Date" 266 | }, 267 | "description": "Filter by end date" 268 | } 269 | ], 270 | "responses": { 271 | "200": { 272 | "description": "Successful Response", 273 | "content": { 274 | "application/json": { 275 | "schema": { 276 | 277 | } 278 | } 279 | } 280 | }, 281 | "422": { 282 | "description": "Validation Error", 283 | "content": { 284 | "application/json": { 285 | "schema": { 286 | "$ref": "#/components/schemas/HTTPValidationError" 287 | } 288 | } 289 | } 290 | } 291 | } 292 | }, 293 | "post": { 294 | "summary": "Create Expense", 295 | "operationId": "create_expense_expenses_post", 296 | "requestBody": { 297 | "required": true, 298 | "content": { 299 | "application/json": { 300 | "schema": { 301 | "$ref": "#/components/schemas/ExpenseCreate" 302 | } 303 | } 304 | } 305 | }, 306 | "responses": { 307 | "201": { 308 | "description": "Successful Response", 309 | "content": { 310 | "application/json": { 311 | "schema": { 312 | 313 | } 314 | } 315 | } 316 | }, 317 | "422": { 318 | "description": "Validation Error", 319 | "content": { 320 | "application/json": { 321 | "schema": { 322 | "$ref": "#/components/schemas/HTTPValidationError" 323 | } 324 | } 325 | } 326 | } 327 | } 328 | }, 329 | "delete": { 330 | "summary": "Delete Expense", 331 | "operationId": "delete_expense_expenses_delete", 332 | "parameters": [ 333 | { 334 | "name": "transaction_id", 335 | "in": "query", 336 | "required": true, 337 | "schema": { 338 | "type": "integer", 339 | "description": "The ID of the transaction to delete", 340 | "title": "Transaction Id" 341 | }, 342 | "description": "The ID of the transaction to delete" 343 | } 344 | ], 345 | "responses": { 346 | "200": { 347 | "description": "Successful Response", 348 | "content": { 349 | "application/json": { 350 | "schema": { 351 | 352 | } 353 | } 354 | } 355 | }, 356 | "422": { 357 | "description": "Validation Error", 358 | "content": { 359 | "application/json": { 360 | "schema": { 361 | "$ref": "#/components/schemas/HTTPValidationError" 362 | } 363 | } 364 | } 365 | } 366 | } 367 | } 368 | } 369 | }, 370 | "components": { 371 | "schemas": { 372 | "CategoryCreate": { 373 | "properties": { 374 | "category_name": { 375 | "type": "string", 376 | "title": "Category Name" 377 | } 378 | }, 379 | "type": "object", 380 | "required": [ 381 | "category_name" 382 | ], 383 | "title": "CategoryCreate" 384 | }, 385 | "ExpenseCreate": { 386 | "properties": { 387 | "date": { 388 | "type": "string", 389 | "format": "date", 390 | "title": "Date" 391 | }, 392 | "category_id": { 393 | "type": "integer", 394 | "title": "Category Id" 395 | }, 396 | "description": { 397 | "type": "string", 398 | "title": "Description" 399 | }, 400 | "amount": { 401 | "anyOf": [ 402 | { 403 | "type": "number" 404 | }, 405 | { 406 | "type": "string" 407 | } 408 | ], 409 | "title": "Amount" 410 | }, 411 | "vat": { 412 | "anyOf": [ 413 | { 414 | "type": "number" 415 | }, 416 | { 417 | "type": "string" 418 | } 419 | ], 420 | "title": "Vat" 421 | }, 422 | "payment_method_id": { 423 | "type": "integer", 424 | "title": "Payment Method Id" 425 | }, 426 | "business_personal": { 427 | "type": "string", 428 | "title": "Business Personal" 429 | } 430 | }, 431 | "type": "object", 432 | "required": [ 433 | "date", 434 | "category_id", 435 | "description", 436 | "amount", 437 | "vat", 438 | "payment_method_id", 439 | "business_personal" 440 | ], 441 | "title": "ExpenseCreate" 442 | }, 443 | "HTTPValidationError": { 444 | "properties": { 445 | "detail": { 446 | "items": { 447 | "$ref": "#/components/schemas/ValidationError" 448 | }, 449 | "type": "array", 450 | "title": "Detail" 451 | } 452 | }, 453 | "type": "object", 454 | "title": "HTTPValidationError" 455 | }, 456 | "PaymentMethodCreate": { 457 | "properties": { 458 | "payment_method_name": { 459 | "type": "string", 460 | "title": "Payment Method Name" 461 | } 462 | }, 463 | "type": "object", 464 | "required": [ 465 | "payment_method_name" 466 | ], 467 | "title": "PaymentMethodCreate" 468 | }, 469 | "ValidationError": { 470 | "properties": { 471 | "loc": { 472 | "items": { 473 | "anyOf": [ 474 | { 475 | "type": "string" 476 | }, 477 | { 478 | "type": "integer" 479 | } 480 | ] 481 | }, 482 | "type": "array", 483 | "title": "Location" 484 | }, 485 | "msg": { 486 | "type": "string", 487 | "title": "Message" 488 | }, 489 | "type": { 490 | "type": "string", 491 | "title": "Error Type" 492 | } 493 | }, 494 | "type": "object", 495 | "required": [ 496 | "loc", 497 | "msg", 498 | "type" 499 | ], 500 | "title": "ValidationError" 501 | } 502 | } 503 | } 504 | } -------------------------------------------------------------------------------- /documents/pg_admin_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwa91/LangGraph-Expense-Tracker/4b56d442ccd53016f338ff477a6ef0f9ac03c64e/documents/pg_admin_screenshot.png -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # Database configuration: 2 | PGHOST=localhost 3 | PGPORT=6025 4 | PGDATABASE=expenses 5 | PGVUSER=expenses 6 | PGPASSWORD=money$ 7 | 8 | # Langchain: 9 | LANGCHAIN_TRACING_V2="true" 10 | LANGCHAIN_ENDPOINT="https://api.smith.langchain.com" 11 | LANGSMITH_API_KEY="" 12 | 13 | 14 | MISTRAL_API_KEY="" 15 | OPENAI_API_KEY="" 16 | ANTHROPIC_API_KEY="" 17 | 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.9.5 2 | aiosignal==1.3.1 3 | annotated-types==0.6.0 4 | anthropic==0.25.6 5 | anyio==4.3.0 6 | appnope==0.1.4 7 | asttokens==2.4.1 8 | attrs==23.2.0 9 | certifi==2024.2.2 10 | charset-normalizer==3.3.2 11 | click==8.1.7 12 | comm==0.2.2 13 | dataclasses-json==0.6.4 14 | debugpy==1.8.1 15 | decorator==5.1.1 16 | defusedxml==0.7.1 17 | distro==1.9.0 18 | executing==2.0.1 19 | fastapi==0.110.2 20 | filelock==3.13.4 21 | frozenlist==1.4.1 22 | fsspec==2024.3.1 23 | h11==0.14.0 24 | httpcore==1.0.5 25 | httpx==0.27.0 26 | huggingface-hub==0.22.2 27 | idna==3.7 28 | ipykernel==6.29.4 29 | ipython==8.23.0 30 | jedi==0.19.1 31 | jsonpatch==1.33 32 | jsonpointer==2.4 33 | jupyter_client==8.6.1 34 | jupyter_core==5.7.2 35 | langchain==0.1.16 36 | langchain-anthropic==0.1.11 37 | langchain-community==0.0.34 38 | langchain-core==0.1.45 39 | langchain-openai==0.1.3 40 | langchain-text-splitters==0.0.1 41 | langgraph==0.0.38 42 | langsmith==0.1.49 43 | marshmallow==3.21.1 44 | matplotlib-inline==0.1.7 45 | multidict==6.0.5 46 | mypy-extensions==1.0.0 47 | nest-asyncio==1.6.0 48 | numpy==1.26.4 49 | openai==1.23.2 50 | orjson==3.10.1 51 | packaging==23.2 52 | parso==0.8.4 53 | pexpect==4.9.0 54 | platformdirs==4.2.0 55 | prompt-toolkit==3.0.43 56 | psutil==5.9.8 57 | psycopg2==2.9.9 58 | ptyprocess==0.7.0 59 | pure-eval==0.2.2 60 | pydantic==2.7.0 61 | pydantic_core==2.18.1 62 | Pygments==2.17.2 63 | python-dateutil==2.9.0.post0 64 | python-dotenv==1.0.1 65 | PyYAML==6.0.1 66 | pyzmq==26.0.2 67 | regex==2024.4.16 68 | requests==2.31.0 69 | six==1.16.0 70 | sniffio==1.3.1 71 | SQLAlchemy==2.0.29 72 | stack-data==0.6.3 73 | starlette==0.37.2 74 | tenacity==8.2.3 75 | tiktoken==0.6.0 76 | tokenizers==0.19.1 77 | tornado==6.4 78 | tqdm==4.66.2 79 | traitlets==5.14.3 80 | typing-inspect==0.9.0 81 | typing_extensions==4.11.0 82 | urllib3==2.2.1 83 | uvicorn==0.29.0 84 | wcwidth==0.2.13 85 | yarl==1.9.4 86 | -------------------------------------------------------------------------------- /src/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwa91/LangGraph-Expense-Tracker/4b56d442ccd53016f338ff477a6ef0f9ac03c64e/src/api/__init__.py -------------------------------------------------------------------------------- /src/api/category_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException 2 | from pydantic import BaseModel 3 | from src.database.db_connection import conn, cursor 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | router = APIRouter() 9 | 10 | class CategoryCreate(BaseModel): 11 | category_name: str 12 | 13 | @router.get("/categories") 14 | def get_categories(): 15 | cursor.execute("SELECT * FROM categories") 16 | categories = cursor.fetchall() 17 | if not categories: 18 | raise HTTPException(status_code=404, detail="Categories not found") 19 | return [{"category_id": c[0], "category_name": c[1]} for c in categories] 20 | 21 | @router.post("/categories", status_code=201) 22 | def create_category(category: CategoryCreate): 23 | cursor.execute( 24 | "INSERT INTO categories (category_name) VALUES (%s) RETURNING category_id", 25 | (category.category_name,) 26 | ) 27 | conn.commit() 28 | category_id = cursor.fetchone()[0] 29 | return {"category_id": category_id, "category_name": category.category_name} 30 | 31 | @router.delete("/categories") 32 | def delete_category(category_id: int): 33 | cursor.execute( 34 | "DELETE FROM categories WHERE category_id = %s RETURNING category_id", 35 | (category_id,) 36 | ) 37 | result = cursor.fetchone() 38 | if not result: 39 | raise HTTPException(status_code=404, detail="Category not found") 40 | conn.commit() 41 | return {"message": "Category deleted successfully"} 42 | -------------------------------------------------------------------------------- /src/api/expenses_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException, Query 2 | from pydantic import BaseModel 3 | from src.database.db_connection import conn, cursor 4 | from dotenv import load_dotenv 5 | from datetime import date 6 | from decimal import Decimal 7 | from typing import Optional 8 | 9 | load_dotenv() 10 | 11 | router = APIRouter() 12 | 13 | class ExpenseCreate(BaseModel): 14 | date: date 15 | category_id: int 16 | description: str 17 | amount: Decimal 18 | vat: Decimal 19 | payment_method_id: int 20 | business_personal: str 21 | 22 | @router.get("/expenses") 23 | def get_expenses( 24 | category_id: Optional[int] = Query(None, description="Filter by category ID"), 25 | payment_method_id: Optional[int] = Query(None, description="Filter by payment method ID"), 26 | start_date: Optional[date] = Query(None, description="Filter by start date"), 27 | end_date: Optional[date] = Query(None, description="Filter by end date"), 28 | ): 29 | sql_query = "SELECT * FROM expenses" 30 | query_params = [] 31 | 32 | if category_id is not None: 33 | sql_query += " WHERE category_id = %s" 34 | query_params.append(category_id) 35 | 36 | if payment_method_id is not None: 37 | if len(query_params) == 0: 38 | sql_query += " WHERE payment_method_id = %s" 39 | else: 40 | sql_query += " AND payment_method_id = %s" 41 | query_params.append(payment_method_id) 42 | 43 | if start_date is not None: 44 | if len(query_params) == 0: 45 | sql_query += " WHERE date >= %s" 46 | else: 47 | sql_query += " AND date >= %s" 48 | query_params.append(start_date) 49 | 50 | if end_date is not None: 51 | if len(query_params) == 0: 52 | sql_query += " WHERE date <= %s" 53 | else: 54 | sql_query += " AND date <= %s" 55 | query_params.append(end_date) 56 | 57 | cursor.execute(sql_query, query_params) 58 | expenses = cursor.fetchall() 59 | 60 | if not expenses: 61 | raise HTTPException(status_code=404, detail="No expenses found for the given filters") 62 | 63 | return [ 64 | { 65 | "transaction_id": e[0], 66 | "date": e[1], 67 | "category_id": e[2], 68 | "description": e[3], 69 | "amount": e[4], 70 | "vat": e[5], 71 | "payment_method_id": e[6], 72 | "business_personal": e[7], 73 | } 74 | for e in expenses 75 | ] 76 | 77 | @router.post("/expenses", status_code=201) 78 | def create_expense(expense: ExpenseCreate): 79 | cursor.execute( 80 | "INSERT INTO expenses (date, category_id, description, amount, vat, payment_method_id, business_personal) " 81 | "VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING transaction_id", 82 | ( 83 | expense.date, 84 | expense.category_id, 85 | expense.description, 86 | expense.amount, 87 | expense.vat, 88 | expense.payment_method_id, 89 | expense.business_personal, 90 | ), 91 | ) 92 | conn.commit() 93 | transaction_id = cursor.fetchone()[0] 94 | return {"transaction_id": transaction_id, "description": expense.description} 95 | 96 | @router.delete("/expenses") 97 | def delete_expense(transaction_id: int = Query( 98 | ..., description="The ID of the transaction to delete" 99 | )): 100 | cursor.execute( 101 | "DELETE FROM expenses WHERE transaction_id = %s RETURNING transaction_id", 102 | (transaction_id,), 103 | ) 104 | result = cursor.fetchone() 105 | if not result: 106 | raise HTTPException(status_code=404, detail="Expense not found") 107 | conn.commit() 108 | return {"message": "Expense deleted successfully"} 109 | -------------------------------------------------------------------------------- /src/api/payment_methods_routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, HTTPException 2 | from pydantic import BaseModel 3 | from src.database.db_connection import conn, cursor 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | router = APIRouter() 9 | 10 | class PaymentMethodCreate(BaseModel): 11 | payment_method_name: str 12 | 13 | @router.get("/payment_methods") 14 | def get_payment_methods(): 15 | cursor.execute("SELECT * FROM payment_methods") 16 | payment_methods = cursor.fetchall() 17 | if not payment_methods: 18 | raise HTTPException(status_code=404, detail="Payment methods not found") 19 | return [{"payment_method_id": p[0], "payment_method_name": p[1]} for p in payment_methods] 20 | 21 | @router.post("/payment_methods", status_code=201) 22 | def create_payment_method(payment_method: PaymentMethodCreate): 23 | cursor.execute( 24 | "INSERT INTO payment_methods (payment_method_name) VALUES (%s) RETURNING payment_method_id", 25 | (payment_method.payment_method_name,) 26 | ) 27 | conn.commit() 28 | payment_method_id = cursor.fetchone()[0] 29 | return {"payment_method_id": payment_method_id, "payment_method_name": payment_method.payment_method_name} 30 | 31 | @router.delete("/payment_methods") 32 | def delete_payment_method(payment_method_id: int): 33 | cursor.execute( 34 | "DELETE FROM payment_methods WHERE payment_method_id = %s RETURNING payment_method_id", 35 | (payment_method_id,) 36 | ) 37 | result = cursor.fetchone() 38 | if not result: 39 | raise HTTPException(status_code=404, detail="Payment method not found") 40 | conn.commit() 41 | return {"message": "Payment method deleted successfully"} 42 | -------------------------------------------------------------------------------- /src/api/run_api.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from src.api.category_routes import router as category_router 3 | from src.api.payment_methods_routes import router as payment_methods_router 4 | from src.api.expenses_routes import router as expenses_router 5 | from dotenv import load_dotenv 6 | import os 7 | 8 | load_dotenv() 9 | 10 | app = FastAPI( 11 | title="Expense Management API", 12 | version="1.0.0" 13 | ) 14 | 15 | app.include_router(category_router) 16 | app.include_router(payment_methods_router) 17 | app.include_router(expenses_router) 18 | -------------------------------------------------------------------------------- /src/chain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwa91/LangGraph-Expense-Tracker/4b56d442ccd53016f338ff477a6ef0f9ac03c64e/src/chain/__init__.py -------------------------------------------------------------------------------- /src/chain/graphstate.py: -------------------------------------------------------------------------------- 1 | from nodes.imageencoder import image_encoder_node 2 | from nodes.jsonparser import json_parsing_node 3 | from nodes.categorizer import categorizer_node 4 | from nodes.humancheck import humancheck_node 5 | from nodes.db_entry import db_entry_node 6 | from nodes.correct import correct_node 7 | 8 | from helpers.get_payment_methods_and_categories import get_payment_methods, get_categories 9 | 10 | from typing import TypedDict, Optional, Dict 11 | from datetime import date 12 | from decimal import Decimal 13 | from langgraph.graph import StateGraph 14 | from dotenv import load_dotenv 15 | from langsmith import Client 16 | import os 17 | from uuid import uuid4 18 | 19 | load_dotenv() 20 | 21 | unique_id = uuid4().hex[:8] 22 | os.environ["LANGCHAIN_PROJECT"] = f"expense-tracker {unique_id}" 23 | 24 | client = Client(api_key=os.environ["LANGSMITH_API_KEY"]) 25 | 26 | class GraphState(TypedDict): 27 | user_decision: Optional[list] 28 | image_base64: Optional[str] 29 | image_location: Optional[str] 30 | date: Optional[date] 31 | category_id: Optional[int] 32 | description: Optional[str] 33 | amount: Optional[Decimal] 34 | vat: Optional[Decimal] 35 | payment_method_id: Optional[int] 36 | business_personal: Optional[str] 37 | category: Optional[str] 38 | payment_method: Optional[str] 39 | payment_methods_dict: Optional[Dict[int, str]] 40 | categories_dict: Optional[Dict[int, str]] 41 | vision_model_name: Optional[str] 42 | categorizer_model_name: Optional[str] 43 | 44 | def create_graph_state() -> GraphState: 45 | payment_methods_dict = get_payment_methods() 46 | categories_dict = get_categories() 47 | 48 | return { 49 | "user_decision": None, 50 | "image_base64": None, 51 | "image_location": None, 52 | "date": None, 53 | "category_id": None, 54 | "description": None, 55 | "amount": None, 56 | "vat": None, 57 | "payment_method_id": None, 58 | "business_personal": None, 59 | "category": None, 60 | "payment_method": None, 61 | "payment_methods_dict": payment_methods_dict, 62 | "categories_dict": categories_dict, 63 | "vision_model_name": "gpt-4-vision-preview", 64 | "categorizer_model_name": "gpt-4-turbo", 65 | } 66 | 67 | def setup_workflow() -> StateGraph: 68 | workflow = StateGraph(GraphState) 69 | 70 | workflow.add_node("image_encoder", image_encoder_node) 71 | workflow.add_node("json_parser", json_parsing_node) 72 | workflow.add_node("categorizer", categorizer_node) 73 | workflow.add_node("humancheck", humancheck_node) 74 | workflow.add_edge("image_encoder", "json_parser") 75 | workflow.add_edge("json_parser", "categorizer") 76 | workflow.add_edge("categorizer", "humancheck") 77 | 78 | workflow.add_node("db_entry", db_entry_node) 79 | workflow.add_node("correct", correct_node) 80 | 81 | def decide_humancheck(state): 82 | if state.get('user_decision') == "accept": 83 | return "db_entry" 84 | elif state.get('user_decision') == "change_model": 85 | return "json_parser" 86 | elif state.get('user_decision') == "correct": 87 | return "correct" 88 | return None 89 | 90 | workflow.add_conditional_edges("humancheck", decide_humancheck, { 91 | "db_entry": "db_entry", 92 | "json_parser": "json_parser", 93 | "correct": "correct" 94 | }) 95 | 96 | workflow.add_edge("correct", "humancheck") 97 | 98 | workflow.set_entry_point("image_encoder") 99 | workflow.set_finish_point("db_entry") 100 | 101 | return workflow 102 | 103 | def main(): 104 | workflow = setup_workflow() 105 | app = workflow.compile() 106 | 107 | initial_state = create_graph_state() 108 | 109 | initial_state["image_location"] = "/Users/jw/developer/expense_tracker/data/walmart-bon.jpeg" 110 | 111 | result = app.invoke(initial_state) 112 | 113 | print("Finished run") 114 | 115 | if __name__ == "__main__": 116 | main() 117 | -------------------------------------------------------------------------------- /src/chain/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwa91/LangGraph-Expense-Tracker/4b56d442ccd53016f338ff477a6ef0f9ac03c64e/src/chain/helpers/__init__.py -------------------------------------------------------------------------------- /src/chain/helpers/get_payment_methods_and_categories.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | def get_payment_methods() -> dict: 4 | url = 'http://localhost:8000/payment_methods' 5 | response = requests.get(url, headers={"accept": "application/json"}) 6 | 7 | if response.status_code == 200: 8 | payment_methods = response.json() 9 | payment_methods_dict = { 10 | item["payment_method_id"]: item["payment_method_name"] 11 | for item in payment_methods 12 | } 13 | return payment_methods_dict 14 | else: 15 | raise Exception("Failed to fetch payment methods. Status code: {}".format(response.status_code)) 16 | 17 | 18 | def get_categories() -> dict: 19 | url = 'http://localhost:8000/categories' 20 | response = requests.get(url, headers={"accept": "application/json"}) 21 | 22 | if response.status_code == 200: 23 | categories = response.json() 24 | categories_dict = { 25 | item["category_id"]: item["category_name"] 26 | for item in categories 27 | } 28 | return categories_dict 29 | else: 30 | raise Exception("Failed to fetch categories. Status code: {}".format(response.status_code)) 31 | -------------------------------------------------------------------------------- /src/chain/nodes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwa91/LangGraph-Expense-Tracker/4b56d442ccd53016f338ff477a6ef0f9ac03c64e/src/chain/nodes/__init__.py -------------------------------------------------------------------------------- /src/chain/nodes/categorizer.py: -------------------------------------------------------------------------------- 1 | from langchain_core.pydantic_v1 import BaseModel, Field 2 | from langchain_anthropic import ChatAnthropic 3 | from langchain_openai import ChatOpenAI 4 | from langchain_core.messages import HumanMessage 5 | 6 | class CategoryData(BaseModel): 7 | category: str = Field(default=None, description="The selected category for the invoice") 8 | 9 | def get_category_list(state): 10 | categories_dict = state.get("categories_dict", {}) 11 | return list(categories_dict.values()) 12 | 13 | def categorize_invoice(state): 14 | receipt_date = state.get("date", None) 15 | receipt_description = state.get("description", None) 16 | receipt_amount = state.get("amount", None) 17 | receipt_vat = state.get("vat", None) 18 | receipt_business_personal = state.get("business_personal", None) 19 | receipt_payment_method = state.get("payment_method", None) 20 | 21 | category_list = get_category_list(state) 22 | 23 | prompt = ( 24 | f"Here's the summary of an invoice: \n" 25 | f"Date: {receipt_date}\n" 26 | f"Description: {receipt_description}\n" 27 | f"Amount: {receipt_amount}\n" 28 | f"VAT: {receipt_vat}\n" 29 | f"Business or Personal: {receipt_business_personal}\n" 30 | f"Payment Method: {receipt_payment_method}\n\n" 31 | f"Select an appropriate category for this invoice from the following list:\n" 32 | f"{', '.join(category_list)}" 33 | ) 34 | 35 | categorizer_model_name = state.get("categorizer_model_name", "gpt-3.5-turbo") 36 | 37 | if "claude" in categorizer_model_name.lower(): 38 | chat = ChatAnthropic(model=categorizer_model_name, temperature=0) 39 | else: 40 | chat = ChatOpenAI(model=categorizer_model_name, temperature=0) 41 | 42 | structured_llm = chat.with_structured_output(CategoryData) 43 | 44 | messages = [HumanMessage(content=[{"type": "text", "text": prompt}])] 45 | 46 | response = structured_llm.invoke(messages) 47 | 48 | selected_category = response.dict().get("category", None) 49 | 50 | new_state = state.copy() 51 | 52 | new_state["category"] = selected_category 53 | 54 | if 'category' in new_state and 'categories_dict' in new_state: 55 | category = new_state["category"] 56 | categories_dict = new_state["categories_dict"] 57 | for key, value in categories_dict.items(): 58 | if value == category: 59 | new_state["category_id"] = key 60 | break 61 | 62 | if 'payment_method' in new_state and 'payment_methods_dict' in new_state: 63 | payment_method = new_state["payment_method"] 64 | payment_methods_dict = new_state["payment_methods_dict"] 65 | for key, value in payment_methods_dict.items(): 66 | if value == payment_method: 67 | new_state["payment_method_id"] = key 68 | break 69 | 70 | return new_state 71 | 72 | def categorizer_node(state): 73 | 74 | updated_state = categorize_invoice(state) 75 | 76 | return updated_state 77 | -------------------------------------------------------------------------------- /src/chain/nodes/correct.py: -------------------------------------------------------------------------------- 1 | from langchain_core.pydantic_v1 import BaseModel, Field 2 | from langchain_anthropic import ChatAnthropic 3 | from langchain_openai import ChatOpenAI 4 | from langchain_core.messages import HumanMessage 5 | 6 | class invoice_summary(BaseModel): 7 | date: str = Field(default=None, description="The date of the receipt") 8 | category: str = Field(default=None, description="") 9 | description: str = Field(default=None, description="A brief description of the payment") 10 | amount: str = Field(default=None, description="The total amount paid") 11 | vat: str = Field(default=None, description="The total VAT (taxes) paid") 12 | business_personal: str = Field(default=None, description="Indicate whether the payment is business or personal") 13 | payment_method: str = Field(default=None, description="Indicate the payment method") 14 | 15 | 16 | def get_category_list(state): 17 | categories_dict = state.get("categories_dict", {}) 18 | return list(categories_dict.values()) 19 | 20 | def get_payment_method_list(state): 21 | payment_method_dict = state.get("payment_method_dict", {}) 22 | return list(payment_method_dict.values()) 23 | 24 | def correct_node(state): 25 | date = state.get("date", "").strip() 26 | category = state.get("category", "").strip() 27 | description = state.get("description", "").strip() 28 | amount = state.get("amount", "").strip() 29 | vat = state.get("vat", "").strip() 30 | business_personal = state.get("business_personal", "").strip() 31 | payment_method = state.get("payment_method", "").strip() 32 | 33 | print(f"Date: {date}") 34 | print(f"Category: {category}") 35 | print(f"Description: {description}") 36 | print(f"Amount: {amount}") 37 | print(f"Vat: {vat}") 38 | print(f"Business or Personal: {business_personal}") 39 | print(f"Payment method: {payment_method}") 40 | 41 | instructions = input("Let the LLM know what it has to change in the above summary of the invoice") 42 | 43 | category_list = get_category_list(state) 44 | payment_method_list = get_payment_method_list(state) 45 | 46 | prompt = ( 47 | f"Here's the summary of an invoice: \n" 48 | f"Date: {date}\n" 49 | f"Category: {category}\n" 50 | f"Description: {description}\n" 51 | f"Amount: {amount}\n" 52 | f"VAT: {vat}\n" 53 | f"Business or Personal: {business_personal}\n" 54 | f"Payment Method: {payment_method}\n\n" 55 | f"improve this summary based on the following user feedback:\n" 56 | f"Userfeedback: {instructions} \n" 57 | f"if the user asks to modify the category, make sure to choose one of the following categories:\n" 58 | f"{', '.join(category_list)}" 59 | f"if the user asks to modify the payment method, make sure to choose one of the following payment methods:\n" 60 | f"{', '.join(payment_method_list)}" 61 | ) 62 | 63 | categorizer_model_name = state.get("categorizer_model_name", "gpt-3.5-turbo") 64 | 65 | if "claude" in categorizer_model_name.lower(): 66 | chat = ChatAnthropic(model=categorizer_model_name, temperature=0) 67 | else: 68 | chat = ChatOpenAI(model=categorizer_model_name, temperature=0) 69 | 70 | structured_llm = chat.with_structured_output(invoice_summary) 71 | 72 | messages = [HumanMessage(content=[{"type": "text", "text": prompt}])] 73 | 74 | response = structured_llm.invoke(messages) 75 | 76 | updated_date = response.dict().get("date", None) 77 | updated_category = response.dict().get("category", None) 78 | updated_description = response.dict().get("description", None) 79 | updated_amount = response.dict().get("amount", None) 80 | updated_vat = response.dict().get("vat", None) 81 | updated_business_personal = response.dict().get("business_personal", None) 82 | updated_payment_method = response.dict().get("payment_method", None) 83 | 84 | new_state = state.copy() 85 | 86 | new_state["date"] = updated_date 87 | new_state["category"] = updated_category 88 | new_state["description"] = updated_description 89 | new_state["amount"] = updated_amount 90 | new_state["vat"] = updated_vat 91 | new_state["business_personal"] = updated_business_personal 92 | new_state["payment_method"] = updated_payment_method 93 | 94 | if 'category' in new_state and 'categories_dict' in new_state: 95 | category = new_state["category"] 96 | categories_dict = new_state["categories_dict"] 97 | for key, value in categories_dict.items(): 98 | if value == category: 99 | new_state["category_id"] = key 100 | break 101 | 102 | if 'payment_method' in new_state and 'payment_methods_dict' in new_state: 103 | payment_method = new_state["payment_method"] 104 | payment_methods_dict = new_state["payment_methods_dict"] 105 | for key, value in payment_methods_dict.items(): 106 | if value == payment_method: 107 | new_state["payment_method_id"] = key 108 | break 109 | 110 | return new_state -------------------------------------------------------------------------------- /src/chain/nodes/db_entry.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | def db_entry_node(state): 4 | def get_str_safe(key): 5 | value = state.get(key, "") 6 | if isinstance(value, str): 7 | return value.strip() 8 | else: 9 | return str(value) # Needs a better fix but for now its ok. 10 | 11 | date = get_str_safe("date") 12 | category_id = get_str_safe("category_id") 13 | description = get_str_safe("description") 14 | amount = get_str_safe("amount") 15 | vat = get_str_safe("vat") 16 | business_personal = get_str_safe("business_personal") 17 | payment_method_id = get_str_safe("payment_method_id") 18 | 19 | url = "http://localhost:8000/expenses" 20 | 21 | data = { 22 | "date": date, 23 | "category_id": category_id, 24 | "description": description, 25 | "amount": amount, 26 | "vat": vat, 27 | "payment_method_id": payment_method_id, 28 | "business_personal": business_personal 29 | } 30 | 31 | # Make the POST request to the endpoint 32 | try: 33 | response = requests.post(url, json=data) 34 | 35 | # Check if the request was successful 36 | if response.status_code == 201: 37 | print("Expense created successfully.") 38 | else: 39 | print("Failed to create expense.") 40 | print("Status Code:", response.status_code) 41 | print("Response:", response.json()) 42 | except requests.exceptions.RequestException as e: 43 | print("Error while making the API request:", e) 44 | -------------------------------------------------------------------------------- /src/chain/nodes/humancheck.py: -------------------------------------------------------------------------------- 1 | def humancheck_node(state): 2 | image_location = state.get("image_location", "").strip() 3 | date = state.get("date", "").strip() 4 | category = state.get("category", "").strip() 5 | description = state.get("description", "").strip() 6 | amount = state.get("amount", "").strip() 7 | vat = state.get("vat", "").strip() 8 | business_personal = state.get("business_personal", "").strip() 9 | payment_method = state.get("payment_method", "").strip() 10 | 11 | print(f"Image location: {image_location}") 12 | print(f"Date: {date}") 13 | print(f"Category: {category}") 14 | print(f"Description: {description}") 15 | print(f"Amount: {amount}") 16 | print(f"Vat: {vat}") 17 | print(f"Business or Personal: {business_personal}") 18 | print(f"Payment method: {payment_method}") 19 | 20 | choice = input("Accept(a), Change Model (m) or Correct (c)? ") 21 | 22 | new_state = state.copy() 23 | 24 | if choice.lower() == 'a': 25 | new_state["user_decision"] = "accept" 26 | elif choice.lower() == 'm': 27 | new_state["user_decision"] = "change_model" 28 | 29 | if new_state["vision_model_name"] == "gpt-4-vision-preview": 30 | new_state["vision_model_name"] = "claude-3-opus-20240229" 31 | else: 32 | new_state["vision_model_name"] = "gpt-4-vision-preview" 33 | 34 | if new_state["categorizer_model_name"] == "gpt-4-turbo": 35 | new_state["categorizer_model_name"] = "claude-3-sonnet-20240229" 36 | else: 37 | new_state["categorizer_model_name"] = "gpt-4-turbo" 38 | 39 | elif choice.lower() == 'c': 40 | new_state["user_decision"] = "correct" 41 | 42 | return new_state 43 | -------------------------------------------------------------------------------- /src/chain/nodes/imageencoder.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | def encode_image(image_path: str) -> str: 4 | with open(image_path, "rb") as image_file: 5 | image_data = image_file.read() 6 | encoded_image = base64.b64encode(image_data).decode("utf-8") 7 | 8 | return encoded_image 9 | 10 | def image_encoder_node(state): 11 | 12 | new_state = state.copy() 13 | 14 | image_location = state.get("image_location", "").strip() 15 | image_base64 = encode_image(image_location) 16 | 17 | new_state["image_base64"] = image_base64 18 | 19 | return new_state 20 | -------------------------------------------------------------------------------- /src/chain/nodes/jsonparser.py: -------------------------------------------------------------------------------- 1 | from langchain_core.pydantic_v1 import BaseModel, Field 2 | from langchain_anthropic import ChatAnthropic 3 | from langchain_core.messages import HumanMessage 4 | from langchain_openai import ChatOpenAI 5 | 6 | class ReceiptData(BaseModel): 7 | date: str = Field(default=None, description="The date of the receipt format should be like 2024-04-25") 8 | description: str = Field(default=None, description="A brief description of the payment") 9 | amount: str = Field(default=None, description="The total amount paid") 10 | vat: str = Field(default=None, description="The total VAT (taxes) paid") 11 | business_personal: str = Field(default=None, description="Indicate whether the payment is business or personal") 12 | payment_method: str = Field(default=None, description="Indicate the payment method") 13 | 14 | def get_payment_methods(state): 15 | payment_methods_dict = state.get("payment_methods_dict", {}) 16 | return list(payment_methods_dict.values()) 17 | 18 | def get_receipt_json(image_base64: str, state: dict): 19 | vision_model_name = state.get("vision_model_name", "gpt-4-vision-preview") 20 | 21 | payment_methods_list = get_payment_methods(state) 22 | 23 | prompt = ( 24 | "Tell me the details of the receipt. Make sure to ALWAYS reply by calling the ReceiptData function.NEVER ask the user to provide additional information.\n" 25 | f"NEVER reply in any other way than caling the function. if you are not sure about some info make a well educated guess, but ALWAYS call the function.\n" 26 | f"Choose one of the following payment methods for the 'payment_method' field:\n{', '.join(payment_methods_list)}" 27 | ) 28 | 29 | if "claude" in vision_model_name.lower(): 30 | chat = ChatAnthropic(model=vision_model_name, temperature=0) 31 | else: 32 | chat = ChatOpenAI(model=vision_model_name, temperature=0) 33 | 34 | structured_llm = chat.with_structured_output(ReceiptData) 35 | 36 | messages = [ 37 | HumanMessage( 38 | content=[ 39 | { 40 | "type": "image_url", 41 | "image_url": { 42 | "url": f"data:image/jpeg;base64,{image_base64}", 43 | }, 44 | }, 45 | {"type": "text", "text": prompt}, 46 | ] 47 | ) 48 | ] 49 | 50 | response = structured_llm.invoke(messages) 51 | 52 | return response.dict() 53 | 54 | def json_parsing_node(state): 55 | 56 | new_state = state.copy() 57 | 58 | image_base64 = state.get("image_base64", "").strip() 59 | 60 | receipt_data = get_receipt_json(image_base64, state) 61 | 62 | new_state["date"] = receipt_data.get("date", None) 63 | new_state["description"] = receipt_data.get("description", None) 64 | new_state["amount"] = receipt_data.get("amount", None) 65 | new_state["vat"] = receipt_data.get("vat", None) 66 | new_state["business_personal"] = receipt_data.get("business_personal", None) 67 | new_state["payment_method"] = receipt_data.get("payment_method", None) 68 | 69 | print("json_parsing_node returning:", new_state) 70 | 71 | return new_state 72 | -------------------------------------------------------------------------------- /src/database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwa91/LangGraph-Expense-Tracker/4b56d442ccd53016f338ff477a6ef0f9ac03c64e/src/database/__init__.py -------------------------------------------------------------------------------- /src/database/create_categories_and_payment_methods.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import requests 3 | 4 | BASE_URL = "http://localhost:8000" 5 | CATEGORIES_ENDPOINT = f"{BASE_URL}/categories" 6 | PAYMENT_METHODS_ENDPOINT = f"{BASE_URL}/payment_methods" 7 | 8 | config_path = "config.yaml" 9 | with open(config_path, "r") as file: 10 | config = yaml.safe_load(file) 11 | 12 | categories = config.get("categories", []) 13 | payment_methods = config.get("payment_methods", []) 14 | 15 | def send_post_request(endpoint, data): 16 | response = requests.post(endpoint, json=data) 17 | if response.status_code in (200, 201): 18 | print(f"Successfully created: {data}") 19 | else: 20 | print(f"Error creating {data}: {response.status_code}") 21 | return response 22 | 23 | for category in categories: 24 | category_data = {"category_name": category} 25 | send_post_request(CATEGORIES_ENDPOINT, category_data) 26 | 27 | for payment_method in payment_methods: 28 | payment_method_data = {"payment_method_name": payment_method} 29 | send_post_request(PAYMENT_METHODS_ENDPOINT, payment_method_data) 30 | -------------------------------------------------------------------------------- /src/database/create_tables.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | from db_connection import conn, cursor 3 | 4 | load_dotenv() 5 | 6 | create_categories_table = """ 7 | CREATE TABLE IF NOT EXISTS categories ( 8 | category_id SERIAL PRIMARY KEY, 9 | category_name VARCHAR(100) UNIQUE 10 | ); 11 | """ 12 | 13 | create_payment_methods_table = """ 14 | CREATE TABLE IF NOT EXISTS payment_methods ( 15 | payment_method_id SERIAL PRIMARY KEY, 16 | payment_method_name VARCHAR(50) UNIQUE 17 | ); 18 | """ 19 | 20 | create_expenses_table = """ 21 | CREATE TABLE IF NOT EXISTS expenses ( 22 | transaction_id SERIAL PRIMARY KEY, 23 | date DATE, 24 | category_id INTEGER, 25 | description TEXT, 26 | amount DECIMAL(10, 2), 27 | vat DECIMAL(10, 2), 28 | payment_method_id INTEGER, 29 | business_personal VARCHAR(50), 30 | declared_on DATE, 31 | FOREIGN KEY (category_id) REFERENCES categories(category_id), 32 | FOREIGN KEY (payment_method_id) REFERENCES payment_methods(payment_method_id) 33 | ); 34 | """ 35 | 36 | cursor.execute(create_categories_table) 37 | cursor.execute(create_payment_methods_table) 38 | cursor.execute(create_expenses_table) 39 | 40 | conn.commit() 41 | 42 | cursor.close() 43 | conn.close() 44 | -------------------------------------------------------------------------------- /src/database/db_connection.py: -------------------------------------------------------------------------------- 1 | import psycopg2 2 | import os 3 | from dotenv import load_dotenv 4 | 5 | load_dotenv() 6 | 7 | PGHOST = os.getenv('PGHOST') 8 | PGPORT = os.getenv('PGPORT') 9 | PGDATABASE = os.getenv('PGDATABASE') 10 | PGUSER = os.getenv('PGUSER') 11 | PGPASSWORD = os.getenv('PGPASSWORD') 12 | 13 | conn = psycopg2.connect( 14 | host=PGHOST, 15 | port=PGPORT, 16 | database=PGDATABASE, 17 | user=PGUSER, 18 | password=PGPASSWORD 19 | ) 20 | 21 | cursor = conn.cursor() 22 | --------------------------------------------------------------------------------