Spaces:
Build error
Build error
| """ | |
| 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() |