Sarathrsk03 commited on
Commit
d904dd8
·
0 Parent(s):
.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