Spaces:
Sleeping
Sleeping
Commit ·
d904dd8
0
Parent(s):
V 0.1
Browse files- .gitignore +24 -0
- .python-version +1 -0
- README.md +0 -0
- app.py +222 -0
- data/catapult.csv +9 -0
- main.py +84 -0
- nodes/__init__.py +12 -0
- nodes/foodMatching.py +60 -0
- nodes/models/models.py +14 -0
- nodes/pydanticGenerator.py +64 -0
- nodes/receiptOCR.py +15 -0
- nodes/splitCalculator.py +69 -0
- pyproject.toml +21 -0
- pyrefly.toml +63 -0
- tools/__init__.py +0 -0
- tools/ocr.py +52 -0
- uv.lock +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
|
| 12 |
+
# IDE
|
| 13 |
+
.idea
|
| 14 |
+
.vscode
|
| 15 |
+
|
| 16 |
+
# Other
|
| 17 |
+
*.DS_Store
|
| 18 |
+
|
| 19 |
+
# environment variables
|
| 20 |
+
.env
|
| 21 |
+
|
| 22 |
+
# Rough Work
|
| 23 |
+
/utils
|
| 24 |
+
/poc
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.13
|
README.md
ADDED
|
File without changes
|
app.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import uuid
|
| 3 |
+
import json
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from main import get_graph
|
| 6 |
+
from nodes.foodMatching import getPeople
|
| 7 |
+
|
| 8 |
+
graph = get_graph()
|
| 9 |
+
MAX_ITEMS = 50
|
| 10 |
+
|
| 11 |
+
def start_or_resume_graph(image_path, thread_id, updates=None):
|
| 12 |
+
config = {"configurable": {"thread_id": thread_id}}
|
| 13 |
+
|
| 14 |
+
if updates:
|
| 15 |
+
graph.update_state(config, updates)
|
| 16 |
+
# Resume - loop through stream to reach next interrupt
|
| 17 |
+
for event in graph.stream(None, config, stream_mode="values"):
|
| 18 |
+
pass
|
| 19 |
+
elif image_path:
|
| 20 |
+
# Start new
|
| 21 |
+
initial_input = {"image_path": image_path}
|
| 22 |
+
for event in graph.stream(initial_input, config, stream_mode="values"):
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
state = graph.get_state(config)
|
| 26 |
+
return state.values, state.next
|
| 27 |
+
|
| 28 |
+
def process_upload(image):
|
| 29 |
+
if not image:
|
| 30 |
+
return [None, "", "", 0.0, pd.DataFrame(columns=["Name", "Price", "Quantity"]), gr.update(visible=False), gr.update(visible=False), "Please upload an image."]
|
| 31 |
+
|
| 32 |
+
thread_id = str(uuid.uuid4())
|
| 33 |
+
values, next_steps = start_or_resume_graph(image, thread_id)
|
| 34 |
+
|
| 35 |
+
receipt_data = values.get("receipt_data", {})
|
| 36 |
+
items = receipt_data.get("items", [])
|
| 37 |
+
df_items = pd.DataFrame(items)
|
| 38 |
+
if df_items.empty:
|
| 39 |
+
df_items = pd.DataFrame(columns=["name", "price", "quantity"])
|
| 40 |
+
else:
|
| 41 |
+
# Standardize column names for the UI dataframe
|
| 42 |
+
df_items = df_items[["name", "price", "quantity"]]
|
| 43 |
+
|
| 44 |
+
return [
|
| 45 |
+
thread_id,
|
| 46 |
+
receipt_data.get("restaurant_name", ""),
|
| 47 |
+
receipt_data.get("date", ""),
|
| 48 |
+
receipt_data.get("total_amount", 0.0),
|
| 49 |
+
df_items,
|
| 50 |
+
gr.update(visible=True),
|
| 51 |
+
gr.update(visible=False),
|
| 52 |
+
"Extracted! Please review and edit the table below if needed."
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
def confirm_receipt(thread_id, res_name, res_date, res_total, items_df):
|
| 56 |
+
# Convert dataframe back to list of dicts
|
| 57 |
+
items = items_df.to_dict("records")
|
| 58 |
+
receipt_data = {
|
| 59 |
+
"restaurant_name": res_name,
|
| 60 |
+
"date": res_date,
|
| 61 |
+
"total_amount": float(res_total),
|
| 62 |
+
"items": items
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
values, next_steps = start_or_resume_graph(None, thread_id, updates={"receipt_data": receipt_data})
|
| 66 |
+
|
| 67 |
+
matched_items = values.get("matched_items", [])
|
| 68 |
+
people = getPeople()
|
| 69 |
+
|
| 70 |
+
# Section visibility updates
|
| 71 |
+
updates = [gr.update(visible=False), gr.update(visible=True)]
|
| 72 |
+
|
| 73 |
+
# Row container visibility updates
|
| 74 |
+
row_visibility = []
|
| 75 |
+
# Component value/choices/visibility updates
|
| 76 |
+
component_updates = []
|
| 77 |
+
|
| 78 |
+
for i in range(MAX_ITEMS):
|
| 79 |
+
if i < len(matched_items):
|
| 80 |
+
item = matched_items[i]
|
| 81 |
+
row_visibility.append(gr.update(visible=True))
|
| 82 |
+
component_updates.extend([
|
| 83 |
+
gr.update(value=item.get("name", ""), visible=True),
|
| 84 |
+
gr.update(value=item.get("price", 0.0), visible=True),
|
| 85 |
+
gr.update(choices=people, value=[], visible=True)
|
| 86 |
+
])
|
| 87 |
+
else:
|
| 88 |
+
row_visibility.append(gr.update(visible=False))
|
| 89 |
+
component_updates.extend([
|
| 90 |
+
gr.update(visible=False),
|
| 91 |
+
gr.update(visible=False),
|
| 92 |
+
gr.update(visible=False)
|
| 93 |
+
])
|
| 94 |
+
|
| 95 |
+
final_updates = updates + row_visibility + component_updates + ["Receipt confirmed. Please assign people to each item using the dropdowns."]
|
| 96 |
+
return final_updates
|
| 97 |
+
|
| 98 |
+
def update_table_total(items_df):
|
| 99 |
+
try:
|
| 100 |
+
total = items_df["price"].astype(float).multiply(items_df["quantity"].astype(int)).sum()
|
| 101 |
+
return round(float(total), 2)
|
| 102 |
+
except:
|
| 103 |
+
return 0.0
|
| 104 |
+
|
| 105 |
+
def calculate_split_v2(thread_id, final_amount, *args):
|
| 106 |
+
# args contains (name1, price1, people1, ...)
|
| 107 |
+
matching_data = args
|
| 108 |
+
|
| 109 |
+
matched_items = []
|
| 110 |
+
for i in range(0, len(matching_data), 3):
|
| 111 |
+
name = matching_data[i]
|
| 112 |
+
price = matching_data[i+1]
|
| 113 |
+
people = matching_data[i+2]
|
| 114 |
+
|
| 115 |
+
if name and price is not None and str(name).strip() != "":
|
| 116 |
+
matched_items.append({
|
| 117 |
+
"name": name,
|
| 118 |
+
"price": float(price),
|
| 119 |
+
"quantity": 1,
|
| 120 |
+
"people": people
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
# Explicitly update the state so that re-runs use the latest assignments
|
| 124 |
+
config = {"configurable": {"thread_id": thread_id}}
|
| 125 |
+
graph.update_state(config, {"matched_items": matched_items, "final_amount_paid": float(final_amount)})
|
| 126 |
+
|
| 127 |
+
# Resume/Re-run the graph
|
| 128 |
+
for event in graph.stream(None, config, stream_mode="values"):
|
| 129 |
+
pass
|
| 130 |
+
|
| 131 |
+
state = graph.get_state(config)
|
| 132 |
+
splits = state.values.get("final_splits", [])
|
| 133 |
+
|
| 134 |
+
if not splits:
|
| 135 |
+
return pd.DataFrame(columns=["Person", "Items Consumed", "Raw Share ($)", "Ratio (%)", "Final Split ($)"])
|
| 136 |
+
return pd.DataFrame(splits)
|
| 137 |
+
|
| 138 |
+
with gr.Blocks(title="Catapult Splitter", theme=gr.themes.Soft()) as demo:
|
| 139 |
+
gr.Markdown("# 🧾 Catapult Receipt Splitter")
|
| 140 |
+
gr.Markdown("Split bills fairly based on consumption ratios.")
|
| 141 |
+
|
| 142 |
+
thread_id_state = gr.State()
|
| 143 |
+
status_msg = gr.Textbox(label="Status", interactive=False)
|
| 144 |
+
|
| 145 |
+
with gr.Row():
|
| 146 |
+
with gr.Column(scale=1):
|
| 147 |
+
image_input = gr.Image(type="filepath", label="Upload Receipt Image")
|
| 148 |
+
upload_btn = gr.Button("1. Extract Content", variant="primary")
|
| 149 |
+
|
| 150 |
+
with gr.Column(scale=2, visible=False) as review_section:
|
| 151 |
+
gr.Markdown("### 2. Review & Edit Items")
|
| 152 |
+
with gr.Row():
|
| 153 |
+
res_name = gr.Textbox(label="Restaurant Name")
|
| 154 |
+
res_date = gr.Textbox(label="Date")
|
| 155 |
+
|
| 156 |
+
items_df = gr.Dataframe(
|
| 157 |
+
headers=["name", "price", "quantity"],
|
| 158 |
+
datatype=["str", "number", "number"],
|
| 159 |
+
column_count=(3, "fixed"),
|
| 160 |
+
label="Check item prices and quantities",
|
| 161 |
+
interactive=True,
|
| 162 |
+
type="pandas"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
with gr.Row():
|
| 166 |
+
res_total = gr.Number(label="Calculated Items Total ($)", interactive=True)
|
| 167 |
+
confirm_btn = gr.Button("2. Confirm Items", variant="primary")
|
| 168 |
+
|
| 169 |
+
# Auto-update the total when table changes
|
| 170 |
+
items_df.change(update_table_total, inputs=[items_df], outputs=[res_total])
|
| 171 |
+
|
| 172 |
+
with gr.Column(visible=False) as matching_section:
|
| 173 |
+
gr.Markdown("### 3. Food Matching")
|
| 174 |
+
gr.Markdown("Assign people to each item. You can select multiple people per item.")
|
| 175 |
+
|
| 176 |
+
matching_rows = []
|
| 177 |
+
matching_row_containers = []
|
| 178 |
+
with gr.Group():
|
| 179 |
+
for i in range(MAX_ITEMS):
|
| 180 |
+
with gr.Row(visible=False) as row:
|
| 181 |
+
item_name = gr.Textbox(label=f"Item {i+1}", interactive=False, scale=2)
|
| 182 |
+
item_price = gr.Number(label="Price", interactive=False, scale=1)
|
| 183 |
+
item_people = gr.Dropdown(label="People", choices=[], multiselect=True, scale=3)
|
| 184 |
+
matching_rows.extend([item_name, item_price, item_people])
|
| 185 |
+
matching_row_containers.append(row)
|
| 186 |
+
|
| 187 |
+
with gr.Row():
|
| 188 |
+
final_paid_input = gr.Number(label="Final Amount Paid (Actual Swipe Amount)", value=0)
|
| 189 |
+
calc_btn = gr.Button("3. Calculate Final Split", variant="primary", size="lg")
|
| 190 |
+
|
| 191 |
+
gr.Markdown("### 💰 Result Summary")
|
| 192 |
+
# Using wrap=True to handle the multi-line "Items Consumed" column
|
| 193 |
+
result_output = gr.Dataframe(label="Final Breakdown", interactive=False, wrap=True)
|
| 194 |
+
|
| 195 |
+
# Flatten matching rows for inputs
|
| 196 |
+
matching_inputs = matching_rows
|
| 197 |
+
|
| 198 |
+
# Wire up events with loading bars
|
| 199 |
+
upload_btn.click(
|
| 200 |
+
process_upload,
|
| 201 |
+
inputs=[image_input],
|
| 202 |
+
outputs=[thread_id_state, res_name, res_date, res_total, items_df, review_section, matching_section, status_msg],
|
| 203 |
+
show_progress=True
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
confirm_outputs = [review_section, matching_section] + matching_row_containers + matching_rows + [status_msg]
|
| 207 |
+
confirm_btn.click(
|
| 208 |
+
confirm_receipt,
|
| 209 |
+
inputs=[thread_id_state, res_name, res_date, res_total, items_df],
|
| 210 |
+
outputs=confirm_outputs,
|
| 211 |
+
show_progress=True
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
calc_btn.click(
|
| 215 |
+
calculate_split_v2,
|
| 216 |
+
inputs=[thread_id_state, final_paid_input] + matching_inputs,
|
| 217 |
+
outputs=[result_output],
|
| 218 |
+
show_progress=True
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
if __name__ == "__main__":
|
| 222 |
+
demo.launch(theme=gr.themes.Soft())
|
data/catapult.csv
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
s.no,name,email
|
| 2 |
+
1,Sarath Rajan S,sarathrajansk@gmail.com
|
| 3 |
+
2,Vidhula,
|
| 4 |
+
3,Gayathri,
|
| 5 |
+
4,Anuradha,
|
| 6 |
+
5,Neha,
|
| 7 |
+
6,Srinithi,
|
| 8 |
+
7,Krishna,
|
| 9 |
+
8,Shuvrdip,
|
main.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Langgraph logic for Receipt Splitting
|
| 3 |
+
Author: Sarath Rajan S
|
| 4 |
+
Date: 26-01-2026
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
from typing import TypedDict, List, Optional, Dict
|
| 10 |
+
from langgraph.graph import StateGraph, START, END
|
| 11 |
+
from langgraph.checkpoint.memory import MemorySaver
|
| 12 |
+
from nodes.pydanticGenerator import pydanticGenerator
|
| 13 |
+
from nodes.foodMatching import foodMatching, getPeople
|
| 14 |
+
from nodes.splitCalculator import splitCalculator
|
| 15 |
+
from nodes.receiptOCR import receiptOCR
|
| 16 |
+
|
| 17 |
+
# Define the state shape
|
| 18 |
+
class GraphState(TypedDict):
|
| 19 |
+
image_path: str
|
| 20 |
+
receipt_text: Optional[str]
|
| 21 |
+
receipt_data: Optional[Dict]
|
| 22 |
+
matched_items: Optional[List[dict]]
|
| 23 |
+
final_amount_paid: Optional[float]
|
| 24 |
+
final_splits: Optional[List[Dict]]
|
| 25 |
+
|
| 26 |
+
def ocr_node(state: GraphState):
|
| 27 |
+
print("--- OCR Node ---")
|
| 28 |
+
text = receiptOCR(state["image_path"])
|
| 29 |
+
return {"receipt_text": text}
|
| 30 |
+
|
| 31 |
+
def pydantic_node(state: GraphState):
|
| 32 |
+
print("--- Pydantic Generator Node ---")
|
| 33 |
+
json_res = pydanticGenerator(state["receipt_text"])
|
| 34 |
+
try:
|
| 35 |
+
data = json.loads(json_res)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
print(f"Error parsing JSON: {e}")
|
| 38 |
+
data = {"items": [], "total_amount": 0}
|
| 39 |
+
|
| 40 |
+
return {"receipt_data": data}
|
| 41 |
+
|
| 42 |
+
def review_node(state: GraphState):
|
| 43 |
+
print("--- Review Node ---")
|
| 44 |
+
# Initialize matched items here after potential edits to receipt_data
|
| 45 |
+
matches = foodMatching(state["receipt_data"], None)
|
| 46 |
+
return {"matched_items": matches}
|
| 47 |
+
|
| 48 |
+
def calculator_node(state: GraphState):
|
| 49 |
+
print("--- Split Calculator Node ---")
|
| 50 |
+
splits = splitCalculator(
|
| 51 |
+
state["receipt_data"],
|
| 52 |
+
state.get("matched_items", []),
|
| 53 |
+
state.get("final_amount_paid")
|
| 54 |
+
)
|
| 55 |
+
return {"final_splits": splits}
|
| 56 |
+
|
| 57 |
+
# Define the Graph
|
| 58 |
+
workflow = StateGraph(GraphState)
|
| 59 |
+
|
| 60 |
+
workflow.add_node("ocr", ocr_node)
|
| 61 |
+
workflow.add_node("pydantic", pydantic_node)
|
| 62 |
+
workflow.add_node("review", review_node)
|
| 63 |
+
workflow.add_node("calculator", calculator_node)
|
| 64 |
+
|
| 65 |
+
workflow.add_edge(START, "ocr")
|
| 66 |
+
workflow.add_edge("ocr", "pydantic")
|
| 67 |
+
workflow.add_edge("pydantic", "review")
|
| 68 |
+
workflow.add_edge("review", "calculator")
|
| 69 |
+
workflow.add_edge("calculator", END)
|
| 70 |
+
|
| 71 |
+
# Set up checkpointer
|
| 72 |
+
memory = MemorySaver()
|
| 73 |
+
|
| 74 |
+
# Compile with interrupts
|
| 75 |
+
# interrupt_before review: let user edit receipt_data (Step 3: Make changes)
|
| 76 |
+
# interrupt_before matching: move to food matching (Step 4)
|
| 77 |
+
# interrupt_before calculator: let user check/edit matched_items and discount (Step 5)
|
| 78 |
+
app = workflow.compile(
|
| 79 |
+
checkpointer=memory,
|
| 80 |
+
interrupt_before=["review", "calculator"]
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
def get_graph():
|
| 84 |
+
return app
|
nodes/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .receiptOCR import receiptOCR
|
| 2 |
+
from .pydanticGenerator import pydanticGenerator
|
| 3 |
+
from .foodMatching import foodMatching
|
| 4 |
+
from .splitCalculator import splitCalculator
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"receiptOCR",
|
| 8 |
+
"pydanticGenerator",
|
| 9 |
+
"foodMatching",
|
| 10 |
+
"splitCalculator",
|
| 11 |
+
]
|
| 12 |
+
|
nodes/foodMatching.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Used to assign who ate which food item for easier splitting of bills
|
| 3 |
+
|
| 4 |
+
Author: Sarath Rajan S
|
| 5 |
+
Date: 26-01-2026
|
| 6 |
+
"""
|
| 7 |
+
from nodes.models.models import Receipt
|
| 8 |
+
|
| 9 |
+
import csv
|
| 10 |
+
|
| 11 |
+
def getPeople():
|
| 12 |
+
"""
|
| 13 |
+
Gets the list of people from the CSV file.
|
| 14 |
+
"""
|
| 15 |
+
people = []
|
| 16 |
+
try:
|
| 17 |
+
with open("data/catapult.csv", "r") as f:
|
| 18 |
+
reader = csv.DictReader(f)
|
| 19 |
+
people = [row["name"] for row in reader]
|
| 20 |
+
except Exception as e:
|
| 21 |
+
print(f"Error reading people: {e}")
|
| 22 |
+
return people
|
| 23 |
+
|
| 24 |
+
def foodMatching(receipt_data: dict, matched_items: list = None):
|
| 25 |
+
"""
|
| 26 |
+
Assigns food items to people based on the receipt.
|
| 27 |
+
If matched_items is already provided (resumed), returns it.
|
| 28 |
+
Otherwise, explodes items with qty > 1 into individual rows.
|
| 29 |
+
"""
|
| 30 |
+
if matched_items:
|
| 31 |
+
return matched_items
|
| 32 |
+
|
| 33 |
+
# Initialize matched items from receipt items
|
| 34 |
+
items = receipt_data.get("items", [])
|
| 35 |
+
new_matches = []
|
| 36 |
+
for item in items:
|
| 37 |
+
try:
|
| 38 |
+
price = float(item.get("price", 0))
|
| 39 |
+
total_qty = int(item.get("quantity", 1))
|
| 40 |
+
except:
|
| 41 |
+
price = 0.0
|
| 42 |
+
total_qty = 1
|
| 43 |
+
|
| 44 |
+
# Explode quantity: if qty is 3, create 3 rows
|
| 45 |
+
unit_price = price # Assuming price in receipt_data is per-unit
|
| 46 |
+
# Check if the AI provided total price instead of unit price
|
| 47 |
+
# (models often fluctuate here, but standard schema is per-item)
|
| 48 |
+
|
| 49 |
+
for q in range(total_qty):
|
| 50 |
+
display_name = item.get("name", "Unknown")
|
| 51 |
+
if total_qty > 1:
|
| 52 |
+
display_name = f"{display_name} ({q+1}/{total_qty})"
|
| 53 |
+
|
| 54 |
+
new_matches.append({
|
| 55 |
+
"name": display_name,
|
| 56 |
+
"price": unit_price,
|
| 57 |
+
"quantity": 1, # Each row represents 1 unit
|
| 58 |
+
"people": []
|
| 59 |
+
})
|
| 60 |
+
return new_matches
|
nodes/models/models.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List
|
| 3 |
+
|
| 4 |
+
class items(BaseModel):
|
| 5 |
+
name: str
|
| 6 |
+
price: float
|
| 7 |
+
quantity: int
|
| 8 |
+
|
| 9 |
+
class Receipt(BaseModel):
|
| 10 |
+
restaurant_name: str
|
| 11 |
+
date: str
|
| 12 |
+
time: str
|
| 13 |
+
items: List[items]
|
| 14 |
+
total_amount: float
|
nodes/pydanticGenerator.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
This module is used to generate structured information from the receipt text.
|
| 3 |
+
|
| 4 |
+
Author: Sarath Rajan S
|
| 5 |
+
Date: 26-01-2026
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from google import genai
|
| 9 |
+
from google.genai import types
|
| 10 |
+
from nodes.models.models import Receipt
|
| 11 |
+
from dotenv import load_dotenv
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
client = genai.Client()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def pydanticGenerator(receipt_text):
|
| 19 |
+
response = client.models.generate_content(
|
| 20 |
+
model='gemini-2.5-flash',
|
| 21 |
+
contents=receipt_text,
|
| 22 |
+
config=types.GenerateContentConfig(
|
| 23 |
+
response_mime_type='application/json',
|
| 24 |
+
response_schema=Receipt,
|
| 25 |
+
),
|
| 26 |
+
)
|
| 27 |
+
return response.text
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
if __name__ == "__main__":
|
| 31 |
+
receipt_text = """
|
| 32 |
+
1 25/01/26 Dine In: $3
|
| 33 |
+
Bill No.; 1147
|
| 34 |
+
Bhier: cashier
|
| 35 |
+
[Note] No Mushroom
|
| 36 |
+
Chicken Classic 1 365.00 365.00
|
| 37 |
+
Hot & Sour Soup
|
| 38 |
+
[Note] No Mushroom
|
| 39 |
+
Crumbs 1 415.00 415.00
|
| 40 |
+
Mushroom
|
| 41 |
+
Croquette
|
| 42 |
+
Fire Cracker Ebi 1 395.00 395.00
|
| 43 |
+
Uramaki (4 Pcs)
|
| 44 |
+
Xo Garlic Sauce 1 595.00 595.00
|
| 45 |
+
Prawn |
|
| 46 |
+
Kaki-Age Uramaki 1 625.00 625.00 |
|
| 47 |
+
(8 Pes)
|
| 48 |
+
Peri-peri Crispy 1 215.00 215.00
|
| 49 |
+
1 195.00 195.00 |
|
| 50 |
+
Vietnamese Crispy 1 595.00 595.00
|
| 51 |
+
Lambs
|
| 52 |
+
a cence on eee ci SS
|
| 53 |
+
Total Qty: 9 oe 3795.00
|
| 54 |
+
vice Charge 379.50
|
| 55 |
+
GEST 2E% 94.88
|
| 56 |
+
CGST 2.5% 94.88
|
| 57 |
+
| Round off 0,26
|
| 58 |
+
| Grand Total 4364.00
|
| 59 |
+
Paid Via Other [Pending]
|
| 60 |
+
OO
|
| 61 |
+
| Service Charge is Optional
|
| 62 |
+
Thank You...! & Visit Us Again...!
|
| 63 |
+
"""
|
| 64 |
+
print(pydanticGenerator(receipt_text))
|
nodes/receiptOCR.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Used to extract the receipt data from the receipt image
|
| 3 |
+
|
| 4 |
+
Author: Sarath Rajan S
|
| 5 |
+
Date: 26-01-2026
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from tools.ocr import extract_text_from_receipt,extract_raw_text
|
| 9 |
+
|
| 10 |
+
def receiptOCR(image_path: str) -> str:
|
| 11 |
+
"""
|
| 12 |
+
Uses OCR to extract the text from the image
|
| 13 |
+
"""
|
| 14 |
+
text = extract_raw_text(image_path)
|
| 15 |
+
return text if text else ""
|
nodes/splitCalculator.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
This module is used to calculate the split of the bill among people.
|
| 3 |
+
|
| 4 |
+
Author: Sarath Rajan S
|
| 5 |
+
Date: 26-01-2026
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
def splitCalculator(receipt_data: dict, matched_items: list, final_amount_paid: float = None):
|
| 9 |
+
"""
|
| 10 |
+
Calculates the split of the bill.
|
| 11 |
+
1. Calculate Raw Individual Total
|
| 12 |
+
2. Ratio = Individual Total / Raw Receipt Total
|
| 13 |
+
3. Final Split = Ratio * Final Amount Paid
|
| 14 |
+
"""
|
| 15 |
+
# Use the total confirmed by the user in the UI as the base for the ratio
|
| 16 |
+
# This allows users to manualy override the "original amount" if needed.
|
| 17 |
+
raw_receipt_total = receipt_data.get("total_amount", 0)
|
| 18 |
+
|
| 19 |
+
# Fallback to sum of items if total_amount is not set/valid
|
| 20 |
+
if raw_receipt_total <= 0:
|
| 21 |
+
raw_receipt_total = sum(float(item.get("price", 0)) * int(item.get("quantity", 1)) for item in matched_items)
|
| 22 |
+
|
| 23 |
+
# If final_amount_paid is not provided or zero, assume no discount/tax
|
| 24 |
+
if final_amount_paid is None or final_amount_paid <= 0:
|
| 25 |
+
final_amount_paid = raw_receipt_total
|
| 26 |
+
|
| 27 |
+
person_data = {} # {name: {"items": [], "raw_sum": 0}}
|
| 28 |
+
|
| 29 |
+
for item in matched_items:
|
| 30 |
+
iname = item.get("name", "Unknown")
|
| 31 |
+
iprice = float(item.get("price", 0))
|
| 32 |
+
iqty = int(item.get("quantity", 1))
|
| 33 |
+
item_total = iprice * iqty
|
| 34 |
+
people = item.get("people", [])
|
| 35 |
+
|
| 36 |
+
if not people:
|
| 37 |
+
people = ["Unassigned"]
|
| 38 |
+
|
| 39 |
+
share = item_total / len(people)
|
| 40 |
+
|
| 41 |
+
for person in people:
|
| 42 |
+
if person not in person_data:
|
| 43 |
+
person_data[person] = {"items": [], "raw_sum": 0}
|
| 44 |
+
|
| 45 |
+
# Since items are now exploded in the matching phase, iqty is usually 1 here
|
| 46 |
+
item_display = f"{iname} @ ${iprice}"
|
| 47 |
+
if iqty > 1:
|
| 48 |
+
item_display = f"{iname} (x{iqty}) @ ${iprice}"
|
| 49 |
+
|
| 50 |
+
person_data[person]["items"].append(item_display)
|
| 51 |
+
person_data[person]["raw_sum"] += share
|
| 52 |
+
|
| 53 |
+
table_data = []
|
| 54 |
+
for person, data in person_data.items():
|
| 55 |
+
# Step: Ratio of person raw total vs original raw total
|
| 56 |
+
ratio = data["raw_sum"] / raw_receipt_total if raw_receipt_total > 0 else 0
|
| 57 |
+
|
| 58 |
+
# Step: Apply final amount paid based on that ratio
|
| 59 |
+
final_cost = ratio * final_amount_paid
|
| 60 |
+
|
| 61 |
+
table_data.append({
|
| 62 |
+
"Person": person,
|
| 63 |
+
"Items Consumed": "\n".join(data["items"]), # Multi-line string for "nested" look
|
| 64 |
+
"Raw Share ($)": round(data["raw_sum"], 2),
|
| 65 |
+
"Ratio (%)": f"{round(ratio * 100, 1)}%",
|
| 66 |
+
"Final Split ($)": round(final_cost, 2)
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
return table_data
|
pyproject.toml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "catapultsplit"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.13"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"google-genai>=1.60.0",
|
| 9 |
+
"langgraph>=1.0.7",
|
| 10 |
+
"pydantic>=2.12.5",
|
| 11 |
+
"pytesseract>=0.3.13",
|
| 12 |
+
"streamlit>=1.53.1",
|
| 13 |
+
"Pillow>=11.1.0",
|
| 14 |
+
"python-dotenv>=1.2.1",
|
| 15 |
+
"gradio>=6.4.0",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
[dependency-groups]
|
| 19 |
+
dev = [
|
| 20 |
+
"pyrefly>=0.49.0",
|
| 21 |
+
]
|
pyrefly.toml
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pyrefly configuration for catapultSplit
|
| 2 |
+
|
| 3 |
+
###### configuring what to type check and where to import from
|
| 4 |
+
|
| 5 |
+
# check all Python files under the containing directory
|
| 6 |
+
project-includes = ["**/*.py*"]
|
| 7 |
+
|
| 8 |
+
# exclude some uninteresting files
|
| 9 |
+
project-excludes = [
|
| 10 |
+
"**/node_modules",
|
| 11 |
+
"**/__pycache__",
|
| 12 |
+
"**/.venv/**",
|
| 13 |
+
"**/.[!/.]*/**",
|
| 14 |
+
"**/poc/**" # Excluding POC if it's just experimental
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
# perform an upward search for `.gitignore`, `.ignore`, and `.git/info/exclude`, and
|
| 18 |
+
# add those to `project-excludes` automatically
|
| 19 |
+
use-ignore-files = true
|
| 20 |
+
|
| 21 |
+
# import project files from "."
|
| 22 |
+
search-path = ["."]
|
| 23 |
+
|
| 24 |
+
# let Pyrefly try to guess your search path
|
| 25 |
+
disable-search-path-heuristics = false
|
| 26 |
+
|
| 27 |
+
###### configuring your python environment
|
| 28 |
+
|
| 29 |
+
# assume we're running on mac (darwin) as per user environment
|
| 30 |
+
python-platform = "darwin"
|
| 31 |
+
|
| 32 |
+
# assume the Python version we're using is 3.13
|
| 33 |
+
python-version = "3.13"
|
| 34 |
+
|
| 35 |
+
# is Pyrefly disallowed from querying for an interpreter?
|
| 36 |
+
skip-interpreter-query = false
|
| 37 |
+
|
| 38 |
+
#### configuring your type check settings
|
| 39 |
+
|
| 40 |
+
# wildcards for which Pyrefly will unconditionally replace the import with `typing.Any`
|
| 41 |
+
replace-imports-with-any = []
|
| 42 |
+
|
| 43 |
+
# wildcards for which Pyrefly will replace the import with `typing.Any` if it can't be found
|
| 44 |
+
ignore-missing-imports = [
|
| 45 |
+
"google.genai.*",
|
| 46 |
+
"langgraph.*",
|
| 47 |
+
"pytesseract.*",
|
| 48 |
+
"streamlit.*",
|
| 49 |
+
"PIL.*"
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
# should Pyrefly skip type checking if we find a generated file?
|
| 53 |
+
ignore-errors-in-generated-code = true
|
| 54 |
+
|
| 55 |
+
# what should Pyrefly do when it encounters a function that is untyped?
|
| 56 |
+
untyped-def-behavior = "check-and-infer-return-type"
|
| 57 |
+
|
| 58 |
+
# can Pyrefly recognize ignore directives other than `# pyrefly: ignore` and `# type: ignore`
|
| 59 |
+
permissive-ignores = true
|
| 60 |
+
|
| 61 |
+
[errors]
|
| 62 |
+
# You can disable specific error codes here if needed, for example:
|
| 63 |
+
# "unused-expression" = false
|
tools/__init__.py
ADDED
|
File without changes
|
tools/ocr.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Uses OCR to extract the raw text from a given image, optimized for receipts.
|
| 3 |
+
|
| 4 |
+
Author: Sarath Rajan S
|
| 5 |
+
Date: 26-01-2026
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import pytesseract
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from PIL import Image, ImageOps, ImageFilter
|
| 11 |
+
|
| 12 |
+
def extract_raw_text(image_path: str) -> Optional[str]:
|
| 13 |
+
"""
|
| 14 |
+
Uses pytesseract to extract the raw text from the image.
|
| 15 |
+
Includes basic preprocessing to improve OCR accuracy for receipts.
|
| 16 |
+
"""
|
| 17 |
+
try:
|
| 18 |
+
# Open the image using PIL
|
| 19 |
+
with Image.open(image_path) as img:
|
| 20 |
+
# 1. Convert to grayscale to reduce noise
|
| 21 |
+
img = ImageOps.grayscale(img)
|
| 22 |
+
|
| 23 |
+
# 2. Enhance contrast and sharpen for better character definition
|
| 24 |
+
img = img.filter(ImageFilter.SHARPEN)
|
| 25 |
+
|
| 26 |
+
# 3. Use custom Tesseract config
|
| 27 |
+
# --psm 4: Assume a single column of text of variable sizes (common in receipts)
|
| 28 |
+
# --oem 3: Default, based on what is available
|
| 29 |
+
custom_config = r'--oem 3 --psm 4'
|
| 30 |
+
|
| 31 |
+
text = pytesseract.image_to_string(img, config=custom_config)
|
| 32 |
+
return str(text).strip() if text else None
|
| 33 |
+
|
| 34 |
+
except Exception as e:
|
| 35 |
+
print(f"Error extracting text from {image_path}: {e}")
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
def extract_text_from_receipt(image_path: str) -> Optional[str]:
|
| 39 |
+
"""
|
| 40 |
+
Alias for extract_raw_text, specifically intended for receipt processing.
|
| 41 |
+
Ensures the text is cleaned up before returning.
|
| 42 |
+
"""
|
| 43 |
+
text = extract_raw_text(image_path)
|
| 44 |
+
if text:
|
| 45 |
+
# Basic cleanup: remove excessive empty lines
|
| 46 |
+
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
| 47 |
+
return '\n'.join(lines)
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
print(extract_text_from_receipt("/Users/sarathrajan/Desktop/catapultSplit/utils/architecture/receipts/receipt-1.jpeg"))
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|