SebastianAndreu's picture
Update app.py
d8c9793 verified
"""
Integrated Makerspace Inventory Management System
Smart inventory management powered by AI
"""
import google.generativeai as genai
import chromadb
from sentence_transformers import SentenceTransformer
import gradio as gr
from PIL import Image
import json
import re
import os
import pandas as pd
from pdf2image import convert_from_path
import pytesseract
from rapidfuzz import process
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch
import datetime
import itertools
import math
import numpy as np
import matplotlib.pyplot as plt
import io
import shutil
from typing import List, Dict, Tuple, Optional
print("=" * 80)
print("🎨 Makerspace Inventory Management System")
print("=" * 80)
print("Modules: Check Out | Add Items | Inventory Analysis")
print("=" * 80 + "\n")
# Gemini API Configuration
GEMINI_API_KEY = "AIzaSyA5_Cx0rriZWtTr1KyEkWCJ6fVyXpUKuJw"
genai.configure(api_key=GEMINI_API_KEY)
gemini_model = genai.GenerativeModel('models/gemini-2.5-flash')
print("βœ… Gemini API configured (gemini-2.5-flash)")
# Initialize shared embedding model for ChromaDB
embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
print("βœ… Embedding model loaded")
# File paths
ITEMS_CSV = "items.csv"
LOCATIONS_CSV = "locations.csv"
CHECKOUTS_CSV = "checkouts.csv"
UPDATE_LOG_CSV = "update_log.csv"
# =============================================================================
# CUSTOM CSS
# =============================================================================
CUSTOM_CSS = """
/* Global styling - Fixed desktop layout */
.gradio-container {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
max-width: 1400px !important;
margin: auto !important;
}
/* Fix all blocks to have consistent width */
.gradio-container .block {
width: 100% !important;
max-width: 100% !important;
}
/* Ensure all rows stay full width */
.gradio-container .row {
width: 100% !important;
}
/* Main menu - gradient background */
#main-menu-container {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 50px 40px;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
margin-bottom: 30px;
width: 100%;
}
#main-menu-title {
color: white !important;
text-align: center;
font-size: 2.8em !important;
font-weight: 700 !important;
margin-bottom: 8px !important;
letter-spacing: -0.5px;
}
#main-menu-subtitle {
color: rgba(255,255,255,0.95) !important;
text-align: center;
font-size: 1.1em !important;
margin-bottom: 40px !important;
font-weight: 300;
}
/* Module buttons in main menu */
.module-button {
background: white !important;
color: #667eea !important;
border: none !important;
padding: 32px 24px !important;
font-size: 1.25em !important;
font-weight: 600 !important;
border-radius: 12px !important;
box-shadow: 0 4px 16px rgba(0,0,0,0.1) !important;
transition: all 0.3s ease !important;
min-height: 120px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.module-button:hover {
transform: translateY(-3px) !important;
box-shadow: 0 6px 24px rgba(0,0,0,0.15) !important;
}
/* Clean module pages - consistent sizing */
.module-page {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
min-height: 600px;
width: 100%;
}
/* Section headers */
.section-header {
color: #1a202c;
font-size: 2.2em;
font-weight: 700;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 3px solid #667eea;
}
/* Subsection headers */
.subsection-header {
color: #2d3748;
font-size: 1.4em;
font-weight: 600;
margin-top: 30px;
margin-bottom: 15px;
}
/* Instructions box */
.instructions-box {
background: #f7fafc;
border-left: 4px solid #667eea;
padding: 20px 25px;
border-radius: 8px;
margin: 20px 0;
}
.instructions-box p {
margin: 10px 0;
line-height: 1.7;
}
/* Buttons - consistent sizing */
button {
min-height: 48px !important;
font-size: 1.05em !important;
font-weight: 600 !important;
border-radius: 8px !important;
padding: 12px 28px !important;
transition: all 0.3s ease !important;
}
.primary-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: white !important;
border: none !important;
}
.primary-button:hover {
transform: translateY(-2px) !important;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4) !important;
}
/* Input fields - consistent sizing */
input, textarea, select {
min-height: 44px !important;
font-size: 1em !important;
}
/* Image upload areas - better styling */
.image-upload-container {
min-height: 400px !important;
}
/* Upload/Webcam tab buttons - make them visible and styled */
/* Use more aggressive selectors for Gradio 5.50 */
.tabs button,
.tab-nav button,
button[id*="component"],
button[id*="upload"],
button[id*="webcam"] {
background: white !important;
border: 2px solid #cbd5e0 !important;
border-radius: 10px !important;
padding: 14px 28px !important;
font-size: 1em !important;
font-weight: 600 !important;
color: #2d3748 !important;
transition: all 0.3s ease !important;
min-width: 130px !important;
margin: 4px !important;
}
.tabs button:hover,
.tab-nav button:hover {
background: #667eea !important;
border-color: #667eea !important;
color: white !important;
transform: translateY(-2px) !important;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3) !important;
}
.tabs button.selected,
.tab-nav button.selected,
button[aria-selected="true"] {
background: #667eea !important;
border-color: #667eea !important;
color: white !important;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4) !important;
}
/* Force ALL SVG icons to be visible with color */
.tabs button svg,
.tab-nav button svg,
button[id*="component"] svg,
button svg {
width: 20px !important;
height: 20px !important;
fill: currentColor !important;
stroke: currentColor !important;
opacity: 1 !important;
visibility: visible !important;
}
.tabs button svg *,
.tab-nav button svg *,
button svg * {
fill: currentColor !important;
stroke: currentColor !important;
opacity: 1 !important;
}
/* Specific for image component tabs */
div[id*="image"] button,
.image-container button {
background: white !important;
color: #2d3748 !important;
border: 2px solid #cbd5e0 !important;
}
div[id*="image"] button:hover,
.image-container button:hover {
background: #667eea !important;
color: white !important;
}
div[id*="image"] button svg,
.image-container button svg {
fill: currentColor !important;
stroke: currentColor !important;
}
[data-testid="image"] [role="tablist"],
.image-container [role="tablist"],
button[role="tab"] {
background: transparent !important;
}
[data-testid="image"] [role="tab"],
.image-container [role="tab"],
button[role="tab"] {
background: white !important;
border: 2px solid #cbd5e0 !important;
border-radius: 10px !important;
padding: 14px 28px !important;
font-size: 1em !important;
font-weight: 600 !important;
color: #2d3748 !important;
transition: all 0.3s ease !important;
min-width: 130px !important;
margin: 4px !important;
}
[data-testid="image"] [role="tab"]:hover,
.image-container [role="tab"]:hover,
button[role="tab"]:hover {
background: #667eea !important;
border-color: #667eea !important;
color: white !important;
transform: translateY(-2px) !important;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3) !important;
}
[data-testid="image"] [role="tab"][aria-selected="true"],
.image-container [role="tab"][aria-selected="true"],
button[role="tab"][aria-selected="true"] {
background: #667eea !important;
border-color: #667eea !important;
color: white !important;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4) !important;
}
/* Icon colors in tabs - force visibility */
[data-testid="image"] [role="tab"] svg,
.image-container [role="tab"] svg,
button[role="tab"] svg {
width: 20px !important;
height: 20px !important;
fill: currentColor !important;
stroke: currentColor !important;
}
[data-testid="image"] [role="tab"] svg path,
[data-testid="image"] [role="tab"] svg line,
[data-testid="image"] [role="tab"] svg circle,
[data-testid="image"] [role="tab"] svg rect,
.image-container [role="tab"] svg path,
button[role="tab"] svg path {
stroke: currentColor !important;
fill: currentColor !important;
stroke-width: 2 !important;
}
/* Ensure tab text is visible */
[data-testid="image"] [role="tab"] span,
.image-container [role="tab"] span,
button[role="tab"] span {
color: inherit !important;
}
/* Upload area */
[data-testid="image"] .upload-container {
background: white !important;
border: 2px dashed #cbd5e0 !important;
border-radius: 12px !important;
padding: 40px !important;
min-height: 350px !important;
}
[data-testid="image"] .upload-container:hover {
border-color: #667eea !important;
background: #f7fafc !important;
}
/* File upload button styling */
.file-upload button {
background: white !important;
border: 2px solid #e2e8f0 !important;
color: #2d3748 !important;
}
.file-upload button:hover {
background: #f7fafc !important;
border-color: #667eea !important;
}
/* Dataframe tables */
.dataframe-container {
min-height: 200px !important;
max-height: 500px !important;
}
/* Markdown containers */
.markdown-container {
line-height: 1.7 !important;
}
/* Accordions */
.gradio-accordion {
border: 1px solid #e2e8f0 !important;
border-radius: 8px !important;
margin: 15px 0 !important;
}
/* Tabs */
.tabs {
border-radius: 8px !important;
margin-top: 20px !important;
}
/* Status messages styling */
.status-loading {
background: #fff3cd;
border-left: 4px solid #ffc107;
color: #856404;
padding: 15px;
border-radius: 6px;
margin: 15px 0;
}
.status-success {
background: #d4edda;
border-left: 4px solid #28a745;
color: #155724;
padding: 15px;
border-radius: 6px;
margin: 15px 0;
}
.status-error {
background: #f8d7da;
border-left: 4px solid #dc3545;
color: #721c24;
padding: 15px;
border-radius: 6px;
margin: 15px 0;
}
/* Remove mobile responsiveness - keep desktop width */
@media (max-width: 768px) {
.gradio-container {
max-width: 1400px !important;
}
}
/* Ensure consistent spacing */
.gap {
gap: 20px !important;
}
/* Column consistency */
.column {
padding: 10px !important;
}
"""
# Example files for grading/testing
EXAMPLE_CHECKOUT_IMAGE = "screwdriver.png" if os.path.exists("screwdriver.png") else None
EXAMPLE_RECEIPT_PDF = "mcmaster_receipt.pdf" if os.path.exists("mcmaster_receipt.pdf") else None
if EXAMPLE_CHECKOUT_IMAGE:
print(f"βœ… Example checkout image found: {EXAMPLE_CHECKOUT_IMAGE}")
if EXAMPLE_RECEIPT_PDF:
print(f"βœ… Example receipt PDF found: {EXAMPLE_RECEIPT_PDF}")
# =============================================================================
# DATA MANAGEMENT FUNCTIONS
# =============================================================================
def load_items():
"""Load items from CSV"""
return pd.read_csv(ITEMS_CSV)
def save_items(df):
"""Save items to CSV"""
df.to_csv(ITEMS_CSV, index=False)
def load_locations():
"""Load locations from CSV"""
return pd.read_csv(LOCATIONS_CSV)
def load_checkouts():
"""Load checkouts from CSV"""
return pd.read_csv(CHECKOUTS_CSV)
def append_checkout(timestamp, user_id, session_id, item_id):
"""Append a new checkout record"""
df = load_checkouts()
new_row = pd.DataFrame([[timestamp, user_id, session_id, item_id]],
columns=["timestamp", "user_id", "session_id", "item_id"])
df = pd.concat([df, new_row], ignore_index=True)
df.to_csv(CHECKOUTS_CSV, index=False)
def get_categories():
"""Get list of all categories"""
df = load_items()
return sorted(df['category'].unique().tolist())
def rebuild_chromadb():
"""Rebuild ChromaDB from current inventory"""
global chroma_client, collection
df = load_items()
chroma_client = chromadb.Client()
try:
chroma_client.delete_collection(name="makerspace_inventory")
except:
pass
collection = chroma_client.create_collection(
name="makerspace_inventory",
metadata={"description": "Makerspace tool inventory"}
)
documents = []
metadatas = []
ids = []
for i, row in df.iterrows():
doc_text = f"{row['item_name']} {row['category']} {row['description']}"
documents.append(doc_text)
metadatas.append({
"item_id": row['item_id'],
"item_name": row['item_name'],
"category": row['category'],
"quantity": str(row['quantity']),
"unit": row['unit'],
"description": row['description'],
"location_id": row['location_id']
})
ids.append(f"item_{i}")
collection.add(
documents=documents,
metadatas=metadatas,
ids=ids
)
print(f"βœ… ChromaDB rebuilt with {len(df)} items")
# Initialize ChromaDB
rebuild_chromadb()
# =============================================================================
# CHECK OUT MODULE FUNCTIONS
# =============================================================================
def retrieve_top_candidates(query_text, top_k=5):
"""Retrieve top matching items from vector database"""
results = collection.query(
query_texts=[query_text],
n_results=top_k
)
candidates = []
if results['metadatas'] and len(results['metadatas'][0]) > 0:
for metadata in results['metadatas'][0]:
candidates.append({
'Item ID': metadata['item_id'],
'Item Name': metadata['item_name'],
'Category': metadata['category'],
'Quantity': int(metadata['quantity']),
'Unit': metadata['unit'],
'Description': metadata['description'],
'Location': metadata['location_id']
})
return candidates
def detect_all_items(image):
"""Detect ALL items in image using Gemini"""
prompt = """Analyze this image and list EVERY distinct tool or item you see.
IMPORTANT: List each item on ONE line only. Do not include additional details like "Type:", "Brand:", etc.
Format your response as:
1. [One-line description including brand and key features]
2. [One-line description including brand and key features]
Example:
1. Digital caliper with LCD display
2. Arduino microcontroller board
If you see only one item, list just that one.
If you see no tools/items, respond with "No items detected."
Focus on items in the FOREGROUND. Ignore background objects unless they are clearly the subject."""
try:
if isinstance(image, str):
image = Image.open(image)
response = gemini_model.generate_content([prompt, image])
response_text = response.text.strip()
if "no items" in response_text.lower():
return "No items detected"
items = []
lines = response_text.split('\n')
for line in lines:
line = line.strip()
if re.match(r'^\d+[\.\)]\s+', line):
item_desc = re.sub(r'^\d+[\.\)]\s+', '', line)
if item_desc:
items.append(item_desc.strip())
if not items and response_text:
items = [response_text]
return items if items else "Error: Could not parse items from response"
except Exception as e:
return f"Error: {str(e)}"
def match_single_item_to_inventory(description):
"""Match a single item description to inventory using ChromaDB"""
try:
candidates = retrieve_top_candidates(description, top_k=3)
if not candidates:
return None
for candidate in candidates:
if candidate['Quantity'] > 0:
return candidate
return None
except Exception as e:
print(f"Error matching item: {e}")
return None
def scan_items_checkout(image, manual_text):
"""Process image or manual text and return matched items"""
detected_items = []
if image is not None:
detected = detect_all_items(image)
if isinstance(detected, list):
detected_items.extend(detected)
elif isinstance(detected, str) and "error" not in detected.lower():
detected_items.append(detected)
if manual_text and manual_text.strip():
manual_items = [item.strip() for item in manual_text.split(',') if item.strip()]
detected_items.extend(manual_items)
if not detected_items:
return None, "⚠️ No items detected. Please upload an image or enter item names manually."
matched_items = []
for desc in detected_items:
match = match_single_item_to_inventory(desc)
if match:
matched_items.append({
'description': desc,
'item': match,
'quantity': 1
})
if not matched_items:
return None, "⚠️ Could not match any detected items to inventory."
return matched_items, ""
def create_checkout_item_display(item_data, index):
"""Create display text for a matched item"""
item = item_data['item']
desc = item_data['description']
display = f"""<div style="background: #f8f9fa; padding: 15px; border-radius: 8px; border-left: 4px solid #667eea; margin: 10px 0;">
<div style="color: #667eea; font-weight: 600; font-size: 0.9em; margin-bottom: 8px;">πŸ” DETECTED ITEM</div>
<div style="color: #2d3748; font-size: 1.05em; margin-bottom: 12px;">{desc}</div>
<div style="display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; font-size: 0.95em;">
<span style="color: #718096; font-weight: 600;">Matched Item:</span><span style="color: #2d3748;">{item['Item Name']}</span>
<span style="color: #718096; font-weight: 600;">Category:</span><span style="color: #2d3748;">{item['Category']}</span>
<span style="color: #718096; font-weight: 600;">Location:</span><span style="color: #2d3748;">{item['Location']}</span>
<span style="color: #718096; font-weight: 600;">Available:</span><span style="color: #2d3748;">{item['Quantity']} {item['Unit']}</span>
</div>
</div>"""
return display
def confirm_checkout_preview(matched_items):
"""Generate checkout confirmation preview"""
if not matched_items:
return "No items to check out."
preview = "# πŸ“‹ Checkout Summary\n\n"
preview += "Please review your items before completing checkout:\n\n"
for i, item_data in enumerate(matched_items, 1):
item = item_data['item']
qty = item_data['quantity']
preview += f"{i}. **{item['Item Name']}** Γ— {qty} (Location: {item['Location']})\n"
preview += f"\n**Total Items:** {len(matched_items)}"
return preview
def process_checkout(matched_items, user_id_input):
"""Process the checkout and update inventory"""
if not matched_items:
return "No items to check out."
if not user_id_input or not user_id_input.strip():
user_id = f"U{np.random.randint(1, 9999):04d}"
else:
user_id = user_id_input.strip()
df_checkouts = load_checkouts()
if len(df_checkouts) > 0:
last_session = df_checkouts['session_id'].max()
session_num = int(last_session[1:]) + 1
else:
session_num = 1
session_id = f"S{session_num:05d}"
df_items = load_items()
timestamp_base = datetime.datetime.now()
checked_out = []
errors = []
for i, item_data in enumerate(matched_items):
item_id = item_data['item']['Item ID']
qty = item_data['quantity']
item_name = item_data['item']['Item Name']
item_idx = df_items[df_items['item_id'] == item_id].index
if len(item_idx) == 0:
errors.append(f"Item {item_name} not found in inventory")
continue
item_idx = item_idx[0]
current_qty = df_items.loc[item_idx, 'quantity']
if current_qty < qty:
errors.append(f"Not enough {item_name} available (requested: {qty}, available: {current_qty})")
continue
df_items.loc[item_idx, 'quantity'] = current_qty - qty
checkout_time = timestamp_base + datetime.timedelta(seconds=i*10)
append_checkout(
checkout_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
user_id,
session_id,
item_id
)
checked_out.append(f"βœ… {item_name} Γ— {qty}")
save_items(df_items)
rebuild_chromadb()
summary = f"# βœ… Checkout Complete!\n\n"
summary += f"**Session ID:** `{session_id}`\n"
summary += f"**User ID:** `{user_id}`\n"
summary += f"**Timestamp:** {timestamp_base.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
if checked_out:
summary += "## Items Checked Out:\n"
for item in checked_out:
summary += f"- {item}\n"
if errors:
summary += "\n## ⚠️ Errors:\n"
for error in errors:
summary += f"- {error}\n"
return summary
# =============================================================================
# ADD ITEMS MODULE FUNCTIONS
# =============================================================================
def extract_text_from_receipt(file_path):
"""Extract text from PDF or image receipt"""
if file_path is None:
return None, []
try:
# Try to find tesseract automatically
import shutil
tesseract_path = shutil.which('tesseract')
if tesseract_path:
pytesseract.pytesseract.tesseract_cmd = tesseract_path
else:
# Try common paths
for path in ['/usr/bin/tesseract', '/usr/local/bin/tesseract', '/bin/tesseract']:
if os.path.exists(path):
pytesseract.pytesseract.tesseract_cmd = path
break
text = ""
if file_path.name.lower().endswith('.pdf'):
# Try multiple possible poppler paths for Hugging Face
poppler_paths = [None, '/usr/bin', '/usr/local/bin', '/bin']
images = None
last_error = None
for poppler_path in poppler_paths:
try:
if poppler_path:
images = convert_from_path(file_path.name, poppler_path=poppler_path)
else:
images = convert_from_path(file_path.name)
break # Success! Exit loop
except Exception as e:
last_error = str(e)
continue
if images is None:
# If all paths failed, return helpful error
return [[True, f"PDF processing unavailable. Please upload images (PNG/JPG) instead. Error: {last_error}", "", ""]], []
for img in images:
text += pytesseract.image_to_string(img) + "\n"
else:
img = Image.open(file_path.name)
text = pytesseract.image_to_string(img)
if not text.strip():
return [["No text extracted", "", "", True]], []
proposals = parse_receipt_text(text)
if not proposals:
return [["No items recognized", "", "", True]], []
table_data = []
for prop in proposals:
table_data.append([
True,
prop['item_name'],
str(prop['quantity']),
prop['match_type']
])
return table_data, proposals
except Exception as e:
return [[True, f"Error: {str(e)}", "", ""]], []
def parse_receipt_text(text):
"""Parse receipt text to extract items and quantities"""
df_items = load_items()
item_names = df_items['item_name'].tolist()
proposals = []
lines = text.split('\n')
for line in lines:
line = line.strip()
if not line or len(line) < 3:
continue
qty_match = re.search(r'(\d+)\s*x?\s*(.+)', line, re.IGNORECASE)
if qty_match:
qty = int(qty_match.group(1))
item_text = qty_match.group(2).strip()
else:
qty = 1
item_text = line
match = process.extractOne(item_text, item_names, score_cutoff=60)
if match:
matched_name = match[0]
confidence = match[1]
item_row = df_items[df_items['item_name'] == matched_name].iloc[0]
proposals.append({
'item_id': item_row['item_id'],
'item_name': matched_name,
'quantity': qty,
'match_type': f"Fuzzy ({confidence}%)",
'original_text': item_text
})
return proposals
def apply_updates_from_table(table_data):
"""Apply inventory updates from edited table"""
# Convert to list if it's a DataFrame
if isinstance(table_data, pd.DataFrame):
if table_data.empty:
return "No updates to apply.", None
table_data = table_data.values.tolist()
if not table_data or len(table_data) == 0:
return "No updates to apply.", None
df_items = load_items()
updated = []
errors = []
for row in table_data:
if not row[0]:
continue
item_name = row[1]
try:
qty = int(row[2])
except:
errors.append(f"Invalid quantity for {item_name}")
continue
item_names = df_items['item_name'].tolist()
match = process.extractOne(item_name, item_names, score_cutoff=60)
if not match:
errors.append(f"Could not find item: {item_name}")
continue
matched_name = match[0]
item_idx = df_items[df_items['item_name'] == matched_name].index[0]
current_qty = df_items.loc[item_idx, 'quantity']
new_qty = current_qty + qty
df_items.loc[item_idx, 'quantity'] = new_qty
updated.append(f"βœ… {matched_name}: {current_qty} β†’ {new_qty} (+{qty})")
if not updated:
return "No items were updated. " + ("\n".join(errors) if errors else ""), None
save_items(df_items)
rebuild_chromadb()
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"{timestamp},Receipt Upload,{len(updated)} items\n"
with open(UPDATE_LOG_CSV, 'a') as f:
f.write(log_entry)
summary = "# βœ… Updates Applied!\n\n"
for item in updated:
summary += f"- {item}\n"
if errors:
summary += "\n## ⚠️ Warnings:\n"
for error in errors:
summary += f"- {error}\n"
return summary, df_items[['item_id', 'item_name', 'category', 'quantity', 'unit', 'location_id']]
def manual_update(item_name, quantity):
"""Manually update an item's quantity"""
if not item_name or not quantity:
return "⚠️ Please provide item name and quantity.", None
try:
qty = int(quantity)
except:
return "⚠️ Invalid quantity.", None
df_items = load_items()
item_names = df_items['item_name'].tolist()
match = process.extractOne(item_name, item_names, score_cutoff=60)
if not match:
return f"❌ Could not find item: {item_name}", None
matched_name = match[0]
item_idx = df_items[df_items['item_name'] == matched_name].index[0]
current_qty = df_items.loc[item_idx, 'quantity']
new_qty = current_qty + qty
df_items.loc[item_idx, 'quantity'] = new_qty
save_items(df_items)
rebuild_chromadb()
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"{timestamp},Manual Update,{matched_name}: {current_qty} β†’ {new_qty}\n"
with open(UPDATE_LOG_CSV, 'a') as f:
f.write(log_entry)
summary = f"βœ… Updated **{matched_name}**: {current_qty} β†’ {new_qty} (+{qty})"
return summary, df_items[['item_id', 'item_name', 'category', 'quantity', 'unit', 'location_id']]
def view_inventory_table(category_filter="All"):
"""View current inventory with optional category filter"""
df_items = load_items()
if category_filter != "All":
df_items = df_items[df_items['category'] == category_filter]
return df_items[['item_id', 'item_name', 'category', 'quantity', 'unit', 'location_id']]
def view_update_history():
"""View update history"""
if not os.path.exists(UPDATE_LOG_CSV) or os.path.getsize(UPDATE_LOG_CSV) == 0:
return "No update history available."
with open(UPDATE_LOG_CSV, 'r') as f:
history = f.read()
return history if history.strip() else "No update history available."
# =============================================================================
# INVENTORY ANALYSIS MODULE FUNCTIONS
# =============================================================================
def ensure_sessions(df_chk, gap_minutes=30):
"""Ensure sessions exist in checkout data"""
df = df_chk.copy()
if "session_id" in df.columns and df['session_id'].notna().all():
return df
if "user_id" not in df.columns or "timestamp" not in df.columns:
df['session_id'] = [f"S{i:05d}" for i in range(1, len(df) + 1)]
return df
df["timestamp"] = pd.to_datetime(df["timestamp"])
df.sort_values(["user_id", "timestamp"], inplace=True)
session_ids = []
last_user = None
last_time = None
session_counter = 0
gap = pd.Timedelta(minutes=gap_minutes)
for row in df.itertuples():
if (row.user_id != last_user) or (last_time is None) or ((row.timestamp - last_time) > gap):
session_counter += 1
current_session_id = f"AUTO_S{session_counter:05d}"
session_ids.append(current_session_id)
last_user = row.user_id
last_time = row.timestamp
df["session_id"] = session_ids
return df
def baskets_from_sessions(df_chk):
"""Create baskets from checkout sessions"""
return df_chk.groupby("session_id")["item_id"].apply(lambda x: set(x)).tolist()
def pair_metrics(baskets, min_support=0.02):
"""Compute support, confidence, lift for item pairs"""
n = len(baskets)
item_counts = {}
pair_counts = {}
for b in baskets:
for i in b:
item_counts[i] = item_counts.get(i, 0) + 1
for a, b_item in itertools.combinations(sorted(b), 2):
pair_counts[(a, b_item)] = pair_counts.get((a, b_item), 0) + 1
rows = []
for (a, b_item), c_ab in pair_counts.items():
supp = c_ab / n
if supp < min_support:
continue
pa = item_counts[a] / n
pb = item_counts[b_item] / n
conf_a_b = supp / pa
conf_b_a = supp / pb
lift = supp / (pa * pb)
rows.append((a, b_item, supp, conf_a_b, conf_b_a, lift))
df = pd.DataFrame(rows, columns=["item_a", "item_b", "support", "conf_a_b", "conf_b_a", "lift"])
df.sort_values(["lift", "support"], ascending=[False, False], inplace=True)
return df, item_counts
def build_distance_matrix(df_loc):
"""Precompute Euclidean distances between all locations"""
loc_ids = df_loc["location_id"].tolist()
coords = {r.location_id: (float(r.x), float(r.y)) for r in df_loc.itertuples()}
dist = {}
for a in loc_ids:
xa, ya = coords[a]
for b in loc_ids:
xb, yb = coords[b]
dist[(a, b)] = math.dist((xa, ya), (xb, yb))
return loc_ids, coords, dist
def total_weighted_distance(item2loc, W, dist):
"""Calculate total weighted distance"""
cost = 0.0
for (a, b), w in W.items():
la = item2loc.get(a)
lb = item2loc.get(b)
if la is None or lb is None:
continue
cost += w * dist[(la, lb)]
return cost
def delta_cost_for_move(item, from_loc, to_loc, item2loc, W, dist):
"""Compute change in cost if item moves from from_loc to to_loc"""
delta = 0.0
for (a, b), w in W.items():
if a == item or b == item:
other = b if a == item else a
other_loc = item2loc.get(other)
if other_loc is None:
continue
old_d = dist[(from_loc, other_loc)]
new_d = dist[(to_loc, other_loc)]
delta += w * (new_d - old_d)
return delta
def greedy_relocate(df_items, df_loc, W, dist, top_k=30, max_moves=15, min_gain=0.05):
"""Greedy relocation algorithm"""
original_item2loc = dict(zip(df_items["item_id"], df_items["location_id"]))
item2loc = original_item2loc.copy()
loc2item = {loc: item for item, loc in item2loc.items()}
all_locs = df_loc["location_id"].tolist()
moved_items = set()
used_empty_targets = set()
recs = []
sorted_pairs = sorted(W.items(), key=lambda x: x[1], reverse=True)[:top_k]
for (a, b), w in sorted_pairs:
for item in (a, b):
if item in moved_items:
continue
from_loc = item2loc[item]
best_cand = None
best_delta = 0.0
best_occ = None
for cand in all_locs:
if cand == from_loc:
continue
occ = loc2item.get(cand)
if occ is None and cand in used_empty_targets:
continue
if occ is not None and occ in moved_items:
continue
delta = delta_cost_for_move(item, from_loc, cand, item2loc, W, dist)
if delta < best_delta:
best_delta = delta
best_cand = cand
best_occ = occ
gain = -best_delta
if best_cand is not None and gain >= min_gain:
cand = best_cand
occ = best_occ
recs.append({
"move_item": item,
"from": from_loc,
"to": cand,
"swap_with": occ if occ else "Empty",
"gain": gain
})
if occ is not None:
item2loc[occ] = from_loc
loc2item[from_loc] = occ
moved_items.add(occ)
else:
del loc2item[from_loc]
used_empty_targets.add(cand)
item2loc[item] = cand
loc2item[cand] = item
moved_items.add(item)
if len(recs) >= max_moves:
break
if len(recs) >= max_moves:
break
df_recs = pd.DataFrame(recs) if recs else None
return df_recs, item2loc
def run_analysis(min_support, top_k_pairs, max_moves, min_gain, progress=gr.Progress()):
"""Run complete inventory analysis with progress tracking"""
progress(0, desc="Loading data...")
df_items = load_items()
df_loc = load_locations()
df_chk = load_checkouts()
id_to_name = dict(zip(df_items['item_id'], df_items['item_name']))
progress(0.2, desc="Processing checkout sessions...")
df_chk = ensure_sessions(df_chk)
baskets = baskets_from_sessions(df_chk)
progress(0.4, desc="Mining frequent item pairs...")
df_pairs, item_counts = pair_metrics(baskets, min_support=min_support)
if df_pairs.empty:
return None, None, "⚠️ No frequent pairs found. Try lowering the minimum support threshold.", None
df_pairs['item_a_name'] = df_pairs['item_a'].map(id_to_name)
df_pairs['item_b_name'] = df_pairs['item_b'].map(id_to_name)
df_pairs_display = df_pairs[['item_a_name', 'item_b_name', 'support', 'lift', 'conf_a_b', 'conf_b_a']]
df_pairs_display.columns = ['Item A', 'Item B', 'Support', 'Lift', 'Confidence A→B', 'Confidence B→A']
progress(0.6, desc="Building distance matrix...")
loc_ids, coords, dist = build_distance_matrix(df_loc)
W = {}
for _, row in df_pairs.iterrows():
W[(row['item_a'], row['item_b'])] = row['lift'] * row['support']
progress(0.7, desc="Calculating current layout cost...")
item2loc_orig = dict(zip(df_items["item_id"], df_items["location_id"]))
cost_before = total_weighted_distance(item2loc_orig, W, dist)
progress(0.8, desc="Optimizing item placement...")
df_recs, item2loc_new = greedy_relocate(
df_items, df_loc, W, dist,
top_k=int(top_k_pairs),
max_moves=int(max_moves),
min_gain=min_gain
)
cost_after = total_weighted_distance(item2loc_new, W, dist)
improvement = cost_before - cost_after
improvement_pct = (improvement / cost_before * 100) if cost_before > 0 else 0
if df_recs is not None and not df_recs.empty:
df_recs['item_name'] = df_recs['move_item'].map(id_to_name)
df_recs_display = df_recs[['item_name', 'from', 'to', 'swap_with', 'gain']]
df_recs_display.columns = ['Item', 'From Location', 'To Location', 'Swap With', 'Distance Saved']
df_recs_display['Distance Saved'] = df_recs_display['Distance Saved'].round(2)
else:
df_recs_display = pd.DataFrame(columns=['Item', 'From Location', 'To Location', 'Swap With', 'Distance Saved'])
progress(0.9, desc="Generating visualization...")
summary = f"""# πŸ“Š Analysis Results
## Pattern Mining Results
We analyzed **{len(baskets)} checkout sessions** and found **{len(df_pairs)} frequent item pairs**.
### Top Discovered Pattern:
- **{df_pairs.iloc[0]['item_a_name']}** ↔ **{df_pairs.iloc[0]['item_b_name']}**
- Lift: **{df_pairs.iloc[0]['lift']:.2f}** (these items are {df_pairs.iloc[0]['lift']:.1f}Γ— more likely to be checked out together)
- Support: **{df_pairs.iloc[0]['support']:.1%}** (appears in {df_pairs.iloc[0]['support']:.1%} of checkouts)
## Layout Optimization Results
### Distance Costs:
- **Before optimization:** {cost_before:.2f} units
- **After optimization:** {cost_after:.2f} units
- **Improvement:** {improvement:.2f} units ({improvement_pct:.1f}% reduction)
### Recommendations:
- **{len(df_recs) if df_recs is not None else 0} moves** suggested
- Total distance saved: **{improvement:.2f} units**
---
πŸ’‘ **How to interpret these results:**
- **Lift > 1**: Items are frequently checked out together
- **Higher support**: Pattern occurs more often
- **Distance savings**: How much walking you'll save by reorganizing
"""
img = visualize_reorganization(df_items, df_loc, df_pairs, df_recs, coords, id_to_name)
progress(1.0, desc="Complete!")
return df_pairs_display, df_recs_display, summary, img
def visualize_reorganization(df_items, df_loc, df_pairs, df_recs, coords, id_to_name):
"""Create visualization of current layout and suggested moves"""
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(18, 8))
ax1.set_title("Current Layout + Frequent Item Pairs", fontsize=16, fontweight='bold', pad=20)
for loc_id, (x, y) in coords.items():
ax1.scatter(x, y, color='#E8E8E8', s=150, alpha=0.7, zorder=1, edgecolors='#999', linewidths=1)
ax1.text(x, y+0.18, loc_id, fontsize=8, ha='center', va='bottom', color='#666')
if df_pairs is not None and not df_pairs.empty:
top_pairs = df_pairs.head(15)
item2loc = dict(zip(df_items['item_id'], df_items['location_id']))
max_lift = top_pairs['lift'].max()
for idx, (_, row) in enumerate(top_pairs.iterrows()):
item_a, item_b = row['item_a'], row['item_b']
if item_a in item2loc and item_b in item2loc:
loc_a = item2loc[item_a]
loc_b = item2loc[item_b]
if loc_a in coords and loc_b in coords:
xa, ya = coords[loc_a]
xb, yb = coords[loc_b]
alpha = 0.3 + (row['lift'] / max_lift) * 0.4
linewidth = 1 + (row['lift'] / max_lift) * 2
ax1.plot([xa, xb], [ya, yb], color='#667eea', alpha=alpha,
linewidth=linewidth, zorder=0)
ax1.set_xlabel("X Coordinate", fontsize=12, fontweight='bold')
ax1.set_ylabel("Y Coordinate", fontsize=12, fontweight='bold')
ax1.grid(True, alpha=0.2, linestyle='--')
ax1.set_facecolor('#F8F9FA')
ax2.set_title("Suggested Reorganization", fontsize=16, fontweight='bold', pad=20)
for loc_id, (x, y) in coords.items():
ax2.scatter(x, y, color='#E8E8E8', s=150, alpha=0.7, zorder=1, edgecolors='#999', linewidths=1)
ax2.text(x, y+0.18, loc_id, fontsize=8, ha='center', va='bottom', color='#666')
if df_recs is not None and not df_recs.empty:
for idx, (_, rec) in enumerate(df_recs.iterrows()):
from_loc = rec['from']
to_loc = rec['to']
if from_loc in coords and to_loc in coords:
x1, y1 = coords[from_loc]
x2, y2 = coords[to_loc]
color = plt.cm.Reds(0.5 + idx * 0.05)
ax2.annotate('', xy=(x2, y2), xytext=(x1, y1),
arrowprops=dict(arrowstyle='->', color=color, lw=2.5, alpha=0.8),
zorder=2)
item_name = id_to_name.get(rec['move_item'], rec['move_item'])
short_name = ' '.join(item_name.split()[:2])
mid_x, mid_y = (x1 + x2) / 2, (y1 + y2) / 2
ax2.text(mid_x, mid_y, short_name, fontsize=9, ha='center',
fontweight='bold',
bbox=dict(boxstyle='round,pad=0.4', facecolor='#FFE5E5',
edgecolor=color, alpha=0.9, linewidth=2),
zorder=3)
ax2.set_xlabel("X Coordinate", fontsize=12, fontweight='bold')
ax2.set_ylabel("Y Coordinate", fontsize=12, fontweight='bold')
ax2.grid(True, alpha=0.2, linestyle='--')
ax2.set_facecolor('#F8F9FA')
plt.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format='png', dpi=120, bbox_inches='tight', facecolor='white')
buf.seek(0)
img = np.array(Image.open(buf))
plt.close(fig)
return img
# =============================================================================
# GRADIO INTERFACE
# =============================================================================
with gr.Blocks(title="Makerspace Inventory System", css=CUSTOM_CSS) as demo:
# State variables
matched_items_state = gr.State(None)
proposals_state = gr.State([])
# Main menu
with gr.Group(visible=True, elem_id="main-menu-container") as main_menu:
gr.Markdown("# πŸ”§ Makerspace Inventory System", elem_id="main-menu-title")
gr.Markdown("*Smart inventory management powered by AI*", elem_id="main-menu-subtitle")
with gr.Row():
checkout_btn = gr.Button("πŸ›’ Check Out Items", size="lg", elem_classes=["module-button"], scale=1)
add_items_btn = gr.Button("πŸ“¦ Add Items", size="lg", elem_classes=["module-button"], scale=1)
analysis_btn = gr.Button("πŸ“Š Inventory Analysis", size="lg", elem_classes=["module-button"], scale=1)
# CHECK OUT MODULE
with gr.Group(visible=False, elem_classes=["module-page"]) as checkout_module:
gr.Markdown("# πŸ›’ Check Out Items", elem_classes=["section-header"])
with gr.Accordion("πŸ“– How to Use", open=False):
gr.Markdown("""
**Step 1:** Upload a photo of items or type item names manually (comma-separated)
**Step 2:** Review detected items and adjust quantities
**Step 3:** Confirm checkout with optional user ID
πŸ’‘ **Tip:** The AI automatically matches items to inventory using smart search!
""")
with gr.Group(visible=True) as checkout_screen1:
gr.Markdown("### πŸ“Έ Scan Items", elem_classes=["subsection-header"])
with gr.Row():
with gr.Column(scale=2):
checkout_image = gr.Image(
type="pil",
label="Upload Image of Items",
sources=["upload", "webcam"],
height=400
)
load_example_checkout_btn = gr.Button("πŸ“Έ Load Example", size="sm", variant="secondary", scale=0, visible=EXAMPLE_CHECKOUT_IMAGE is not None)
with gr.Column(scale=1):
gr.Markdown("""
### Quick Guide
**Upload:** Click the upload icon to select an image from your device
**Webcam:** Click the camera icon to take a photo with your webcam
πŸ’‘ Make sure items are clearly visible and well-lit
""")
checkout_manual = gr.Textbox(
label="Or Enter Item Names Manually",
placeholder="e.g., drill, safety glasses, Arduino",
info="Separate multiple items with commas"
)
checkout_status = gr.Markdown("")
checkout_scan_btn = gr.Button("πŸ” Scan & Match Items", variant="primary", size="lg")
with gr.Group(visible=False) as checkout_screen2:
gr.Markdown("### Review Selected Items", elem_classes=["subsection-header"])
gr.Markdown("*Verify items, adjust quantities, or remove unwanted items before proceeding*")
checkout_item_controls = []
for i in range(10):
with gr.Row(visible=False) as item_row:
with gr.Column(scale=3):
item_info = gr.Markdown("")
with gr.Column(scale=1):
item_qty = gr.Number(value=1, minimum=1, label="Qty")
with gr.Column(scale=1):
item_remove = gr.Button("πŸ—‘οΈ Remove", size="sm", variant="stop")
checkout_item_controls.append({
'row': item_row,
'info': item_info,
'qty': item_qty,
'remove': item_remove
})
with gr.Row():
checkout_rescan_btn = gr.Button("↩️ Rescan", variant="secondary")
checkout_confirm_btn = gr.Button("βœ… Proceed to Checkout", variant="primary", size="lg")
with gr.Group(visible=False) as checkout_screen3:
gr.Markdown("### 🎫 Confirm Checkout", elem_classes=["subsection-header"])
checkout_preview = gr.Markdown("")
checkout_user_id = gr.Textbox(
label="User ID (Optional)",
placeholder="Enter your ID or leave blank",
info="Leave blank for auto-generated ID"
)
checkout_processing = gr.Markdown("")
with gr.Row():
checkout_cancel_btn = gr.Button("❌ Cancel", variant="secondary")
checkout_final_btn = gr.Button("βœ… Complete Checkout", variant="primary", size="lg")
checkout_result = gr.Markdown("")
with gr.Row():
checkout_return_btn = gr.Button("🏠 Return to Main Menu", variant="secondary")
checkout_another_btn = gr.Button("πŸ›’ Check Out More Items", variant="primary", visible=False)
# ADD ITEMS MODULE
with gr.Group(visible=False, elem_classes=["module-page"]) as add_items_module:
gr.Markdown("# πŸ“¦ Add Items to Inventory", elem_classes=["section-header"])
with gr.Accordion("πŸ“– How to Use", open=False):
gr.Markdown("""
**Receipt Upload:**
1. Upload receipt image or PDF β†’ AI extracts items
2. Review and edit detected items (check/uncheck, edit quantities)
3. Click "Apply Updates" to add to inventory
**Manual Entry:**
- Enter item name and quantity for quick updates
- System uses fuzzy matching to find items
**View & History:**
- Browse inventory with category filters
- Track all changes with timestamps
""")
with gr.Tab("πŸ“„ Receipt Upload"):
gr.Markdown("### Upload Receipt", elem_classes=["subsection-header"])
receipt_file = gr.File(label="Upload Receipt (PDF or Image)", file_types=[".pdf", ".png", ".jpg", ".jpeg"])
load_example_receipt_btn = gr.Button("πŸ“„ Load Example", size="sm", variant="secondary", scale=0, visible=EXAMPLE_RECEIPT_PDF is not None)
receipt_status = gr.Markdown("")
gr.Markdown("### Review Detected Items", elem_classes=["subsection-header"])
gr.Markdown("*Check items to include, edit quantities, then apply*")
add_items_table = gr.Dataframe(
headers=["Include", "Item Name", "Quantity", "Match Confidence"],
label="Detected Items",
datatype=["bool", "str", "number", "str"],
interactive=True,
col_count=(4, "fixed")
)
add_items_status = gr.Markdown("")
with gr.Row():
add_items_reject_btn = gr.Button("❌ Clear All", variant="secondary")
add_items_confirm_btn = gr.Button("βœ… Apply Updates", variant="primary", interactive=False)
with gr.Tab("✍️ Manual Entry"):
gr.Markdown("### Manually Add Items", elem_classes=["subsection-header"])
with gr.Row():
manual_item = gr.Textbox(
label="Item Name",
placeholder="e.g., Arduino Uno",
info="Fuzzy matching will find similar items"
)
manual_qty = gr.Number(
label="Quantity to Add",
value=1,
minimum=1
)
manual_apply_btn = gr.Button("βž• Add to Inventory", variant="primary")
manual_status = gr.Markdown("")
manual_inventory_display = gr.Dataframe(label="Updated Inventory", visible=False)
with gr.Tab("πŸ“Š View & History"):
gr.Markdown("### Current Inventory", elem_classes=["subsection-header"])
with gr.Row():
inventory_category_filter = gr.Dropdown(
choices=["All"] + get_categories(),
value="All",
label="Filter by Category"
)
view_inventory_btn = gr.Button("πŸ”„ Refresh Inventory")
inventory_display = gr.Dataframe(label="Current Inventory", visible=False)
gr.Markdown("### Update History", elem_classes=["subsection-header"])
view_history_btn = gr.Button("πŸ“œ View Update Log")
history_display = gr.Textbox(label="Update History", lines=10, visible=False)
add_items_return_btn = gr.Button("🏠 Return to Main Menu", variant="secondary")
# INVENTORY ANALYSIS MODULE
with gr.Group(visible=False, elem_classes=["module-page"]) as analysis_module:
gr.Markdown("# πŸ“Š Inventory Layout Analysis", elem_classes=["section-header"])
with gr.Accordion("πŸ“– Understanding the Analysis", open=True):
gr.Markdown("""
This module analyzes checkout patterns to optimize item placement and minimize walking distance.
### πŸ“ˆ Key Metrics:
**Support** - Frequency of co-occurrence (e.g., 0.10 = 10% of checkouts)
**Lift** - Correlation strength (Lift > 1 = items checked out together more than random)
**Confidence** - Conditional probability (e.g., 0.80 = 80% chance of B when checking A)
**Distance Saved** - Walking distance reduction in grid units
### 🎯 Goal:
Items frequently checked together should be placed closer to reduce travel time!
""")
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("### βš™οΈ Parameters", elem_classes=["subsection-header"])
min_support = gr.Slider(
minimum=0.01,
maximum=0.2,
value=0.02,
step=0.01,
label="Minimum Support",
info="Lower = more patterns (less significant)"
)
top_k_pairs = gr.Slider(
minimum=5,
maximum=50,
value=25,
step=5,
label="Top K Pairs",
info="Number of frequent pairs to optimize"
)
max_moves = gr.Slider(
minimum=5,
maximum=30,
value=10,
step=1,
label="Maximum Moves",
info="Limit on relocations"
)
min_gain = gr.Slider(
minimum=0.0,
maximum=1.0,
value=0.05,
step=0.01,
label="Minimum Distance Gain",
info="Threshold for suggestions"
)
run_analysis_btn = gr.Button("πŸ” Run Analysis", variant="primary", size="lg")
with gr.Column(scale=2):
gr.Markdown("### πŸ“Š Summary", elem_classes=["subsection-header"])
analysis_summary = gr.Textbox(label="", lines=14, show_label=False)
gr.Markdown("### πŸ—ΊοΈ Visual Layout Comparison", elem_classes=["subsection-header"])
analysis_viz = gr.Image(label="", type="numpy", show_label=False)
with gr.Row():
with gr.Column():
gr.Markdown("### πŸ”— Frequent Pairs", elem_classes=["subsection-header"])
pairs_table = gr.Dataframe(label="", show_label=False)
with gr.Column():
gr.Markdown("### πŸ“ Recommendations", elem_classes=["subsection-header"])
recs_table = gr.Dataframe(label="", show_label=False)
analysis_return_btn = gr.Button("🏠 Return to Main Menu", variant="secondary")
# EVENT HANDLERS - Navigation
def show_checkout():
return [
gr.update(visible=False), # main_menu
gr.update(visible=True), # checkout_module
gr.update(visible=False), # add_items_module
gr.update(visible=False) # analysis_module
]
def show_add_items():
return [
gr.update(visible=False), # main_menu
gr.update(visible=False), # checkout_module
gr.update(visible=True), # add_items_module
gr.update(visible=False) # analysis_module
]
def show_analysis():
return [
gr.update(visible=False), # main_menu
gr.update(visible=False), # checkout_module
gr.update(visible=False), # add_items_module
gr.update(visible=True) # analysis_module
]
def return_to_menu():
return [
gr.update(visible=True), # main_menu
gr.update(visible=False), # checkout_module
gr.update(visible=False), # add_items_module
gr.update(visible=False) # analysis_module
]
checkout_btn.click(fn=show_checkout, outputs=[main_menu, checkout_module, add_items_module, analysis_module])
add_items_btn.click(fn=show_add_items, outputs=[main_menu, checkout_module, add_items_module, analysis_module])
analysis_btn.click(fn=show_analysis, outputs=[main_menu, checkout_module, add_items_module, analysis_module])
checkout_return_btn.click(fn=return_to_menu, outputs=[main_menu, checkout_module, add_items_module, analysis_module])
add_items_return_btn.click(fn=return_to_menu, outputs=[main_menu, checkout_module, add_items_module, analysis_module])
analysis_return_btn.click(fn=return_to_menu, outputs=[main_menu, checkout_module, add_items_module, analysis_module])
# EVENT HANDLERS - Checkout
def update_checkout_screen2(matched_items):
updates = []
if not matched_items:
for i in range(10):
updates.extend([gr.update(visible=False), gr.update(value=""), gr.update(value=1)])
return updates
for i in range(10):
if i < len(matched_items):
item_data = matched_items[i]
updates.extend([
gr.update(visible=True),
gr.update(value=create_checkout_item_display(item_data, i)),
gr.update(value=item_data['quantity'], maximum=item_data['item']['Quantity']),
])
else:
updates.extend([gr.update(visible=False), gr.update(value=""), gr.update(value=1)])
return updates
def remove_checkout_item(matched_items, item_idx):
if matched_items and 0 <= item_idx < len(matched_items):
matched_items.pop(item_idx)
return matched_items
def set_checkout_quantity(matched_items, item_idx, new_qty):
if matched_items and 0 <= item_idx < len(matched_items):
max_qty = matched_items[item_idx]['item']['Quantity']
matched_items[item_idx]['quantity'] = max(1, min(int(new_qty), max_qty))
return matched_items
def reset_checkout():
return (
gr.update(visible=True), gr.update(visible=False), gr.update(visible=False),
None, "", None, "", "", gr.update(visible=False)
)
def show_checkout_complete_options():
return gr.update(visible=False), gr.update(visible=True)
checkout_scan_btn.click(
fn=lambda: (gr.update(value="⏳ Scanning...", interactive=False), "πŸ” **Processing...** AI is analyzing your image..."),
outputs=[checkout_scan_btn, checkout_status]
).then(
fn=scan_items_checkout,
inputs=[checkout_image, checkout_manual],
outputs=[matched_items_state, checkout_status]
).then(
fn=lambda: gr.update(value="πŸ” Scan & Match Items", interactive=True),
outputs=[checkout_scan_btn]
).then(
fn=lambda items: (
gr.update(visible=False) if items else gr.update(),
gr.update(visible=True) if items else gr.update(),
gr.update(visible=False)
),
inputs=[matched_items_state],
outputs=[checkout_screen1, checkout_screen2, checkout_screen3]
).then(
fn=update_checkout_screen2,
inputs=[matched_items_state],
outputs=[checkout_item_controls[i][key] for i in range(10) for key in ['row', 'info', 'qty']]
)
for i in range(10):
checkout_item_controls[i]['qty'].change(
fn=lambda items, new_qty, idx=i: set_checkout_quantity(items, idx, new_qty),
inputs=[matched_items_state, checkout_item_controls[i]['qty']],
outputs=[matched_items_state]
)
checkout_item_controls[i]['remove'].click(
fn=lambda items, idx=i: remove_checkout_item(items, idx),
inputs=[matched_items_state],
outputs=[matched_items_state]
).then(
fn=update_checkout_screen2,
inputs=[matched_items_state],
outputs=[checkout_item_controls[j][key] for j in range(10) for key in ['row', 'info', 'qty']]
)
checkout_confirm_btn.click(
fn=confirm_checkout_preview,
inputs=[matched_items_state],
outputs=[checkout_preview]
).then(
fn=lambda: (gr.update(visible=False), gr.update(visible=False), gr.update(visible=True)),
outputs=[checkout_screen1, checkout_screen2, checkout_screen3]
)
checkout_rescan_btn.click(
fn=reset_checkout,
outputs=[checkout_screen1, checkout_screen2, checkout_screen3, matched_items_state,
checkout_status, checkout_image, checkout_manual, checkout_result, checkout_another_btn]
)
checkout_cancel_btn.click(
fn=reset_checkout,
outputs=[checkout_screen1, checkout_screen2, checkout_screen3, matched_items_state,
checkout_status, checkout_image, checkout_manual, checkout_result, checkout_another_btn]
)
checkout_final_btn.click(
fn=lambda: (gr.update(value="⏳ Processing...", interactive=False), "⏳ **Processing checkout...** Updating inventory..."),
outputs=[checkout_final_btn, checkout_processing]
).then(
fn=process_checkout,
inputs=[matched_items_state, checkout_user_id],
outputs=[checkout_result]
).then(
fn=lambda: (gr.update(value="βœ… Complete Checkout", interactive=True), ""),
outputs=[checkout_final_btn, checkout_processing]
).then(
fn=lambda: (gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)),
outputs=[checkout_screen1, checkout_screen2, checkout_screen3]
).then(
fn=show_checkout_complete_options,
outputs=[checkout_return_btn, checkout_another_btn]
)
checkout_another_btn.click(
fn=reset_checkout,
outputs=[checkout_screen1, checkout_screen2, checkout_screen3, matched_items_state,
checkout_status, checkout_image, checkout_manual, checkout_result, checkout_another_btn]
).then(
fn=lambda: (gr.update(visible=True), gr.update(visible=False)),
outputs=[checkout_return_btn, checkout_another_btn]
)
# EVENT HANDLERS - Add Items
receipt_file.change(
fn=lambda: "πŸ“„ **Processing receipt...** Extracting text with OCR...",
outputs=[receipt_status]
).then(
fn=extract_text_from_receipt,
inputs=[receipt_file],
outputs=[add_items_table, proposals_state]
).then(
fn=lambda proposals: (gr.update(interactive=len(proposals) > 0), "βœ… Items detected! Review and edit the table below."),
inputs=[proposals_state],
outputs=[add_items_confirm_btn, receipt_status]
)
add_items_confirm_btn.click(
fn=lambda: (gr.update(value="⏳ Applying...", interactive=False), "⏳ **Updating inventory...**"),
outputs=[add_items_confirm_btn, add_items_status]
).then(
fn=apply_updates_from_table,
inputs=[add_items_table],
outputs=[add_items_status, inventory_display]
).then(
fn=lambda: (gr.update(value="βœ… Apply Updates", interactive=False), gr.update(visible=True)),
outputs=[add_items_confirm_btn, inventory_display]
)
add_items_reject_btn.click(
fn=lambda: ([["No items", "", "", ""]], "❌ Cleared all items.", []),
outputs=[add_items_table, add_items_status, proposals_state]
).then(
fn=lambda: gr.update(interactive=False),
outputs=[add_items_confirm_btn]
)
manual_apply_btn.click(
fn=lambda: gr.update(value="⏳ Adding...", interactive=False),
outputs=[manual_apply_btn]
).then(
fn=manual_update,
inputs=[manual_item, manual_qty],
outputs=[manual_status, manual_inventory_display]
).then(
fn=lambda: (gr.update(value="βž• Add to Inventory", interactive=True), gr.update(visible=True)),
outputs=[manual_apply_btn, manual_inventory_display]
)
view_inventory_btn.click(
fn=view_inventory_table,
inputs=[inventory_category_filter],
outputs=[inventory_display]
).then(
fn=lambda: gr.update(visible=True),
outputs=[inventory_display]
)
inventory_category_filter.change(
fn=view_inventory_table,
inputs=[inventory_category_filter],
outputs=[inventory_display]
)
view_history_btn.click(
fn=view_update_history,
outputs=[history_display]
).then(
fn=lambda: gr.update(visible=True),
outputs=[history_display]
)
# EVENT HANDLERS - Analysis
run_analysis_btn.click(
fn=run_analysis,
inputs=[min_support, top_k_pairs, max_moves, min_gain],
outputs=[pairs_table, recs_table, analysis_summary, analysis_viz]
)
# EXAMPLE INPUTS
def load_checkout_example():
try:
if EXAMPLE_CHECKOUT_IMAGE:
return Image.open(EXAMPLE_CHECKOUT_IMAGE)
except:
pass
return None
load_example_checkout_btn.click(
fn=load_checkout_example,
outputs=[checkout_image]
)
def load_receipt_example():
try:
if EXAMPLE_RECEIPT_PDF:
return EXAMPLE_RECEIPT_PDF
except:
pass
return None
load_example_receipt_btn.click(
fn=load_receipt_example,
outputs=[receipt_file]
)
if __name__ == "__main__":
demo.queue()
demo.launch()