Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .dockerignore +6 -0
- .env.example +20 -0
- .gitignore +2 -0
- Dockerfile +17 -0
- README.md +37 -4
- app/__init__.py +1 -0
- app/agents/__init__.py +1 -0
- app/agents/missing_regulars_agent.py +192 -0
- app/agents/recipe_agent.py +468 -0
- app/agents/recipe_parser.py +115 -0
- app/agents/shopping_assistant_agent.py +1052 -0
- app/agents/tools/__init__.py +1 -0
- app/agents/tools/quantity_parser_tool.py +206 -0
- app/config.py +25 -0
- app/main.py +43 -0
- app/models/__init__.py +3 -0
- app/models/chat.py +26 -0
- app/routes/__init__.py +1 -0
- app/routes/cart_analysis.py +44 -0
- app/routes/chat.py +109 -0
- app/routes/detection.py +388 -0
- app/routes/health.py +18 -0
- app/services/__init__.py +1 -0
- app/services/chroma.py +62 -0
- app/services/detector.py +35 -0
- app/services/http_client.py +13 -0
- app/services/nutrition_service.py +177 -0
- app/services/object_detector.py +199 -0
- app/services/quantity_normalizer_service.py +625 -0
- app/services/supabase.py +72 -0
- app/utils/cart_state.py +68 -0
- inventory.json +521 -0
- requirements.txt +12 -0
- run_local.sh +27 -0
- test_agent.py +22 -0
- tests/test_groq.py +7 -0
- tests/test_milk.py +13 -0
- tests/test_mixed_veg.py +27 -0
- tests/test_nutrition.py +25 -0
- tests/test_onion.py +27 -0
- tests/test_parser.py +9 -0
- tests/test_pipeline.py +89 -0
- tests/test_quantity.py +24 -0
- tests/test_rice.py +30 -0
- tests/test_scaling.py +7 -0
- tests/verify_usda_measures.py +136 -0
.dockerignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv
|
| 2 |
+
__pycache__
|
| 3 |
+
*.pyc
|
| 4 |
+
captured_images
|
| 5 |
+
.git
|
| 6 |
+
.gitignore
|
.env.example
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Groq API Credentials (for LLM Agents)
|
| 2 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 3 |
+
# Model to use (defaults to llama-3.1-8b-instant if not set; llama-3.3-70b-versatile is also supported but has lower free-tier rate limits)
|
| 4 |
+
GROQ_MODEL=llama-3.1-8b-instant
|
| 5 |
+
|
| 6 |
+
# ChromaDB Cloud Credentials
|
| 7 |
+
CHROMA_API_KEY=your_chroma_api_key_here
|
| 8 |
+
|
| 9 |
+
# Supabase β inventory database
|
| 10 |
+
SUPABASE_URL=https://<your-project-ref>.supabase.co
|
| 11 |
+
SUPABASE_ANON_KEY=your_supabase_anon_key_here
|
| 12 |
+
|
| 13 |
+
# Object Detection (YOLOv8n ONNX) β crops detected objects before CLIP
|
| 14 |
+
ENABLE_OBJECT_DETECTION=false
|
| 15 |
+
YOLO_MODEL_ID=ultralytics/yolov8n
|
| 16 |
+
YOLO_MODEL_FILENAME=yolov8n.onnx
|
| 17 |
+
DETECTION_CONFIDENCE_THRESHOLD=0.25
|
| 18 |
+
DETECTION_IOU_THRESHOLD=0.45
|
| 19 |
+
MAX_DETECTIONS=3
|
| 20 |
+
CROP_PADDING_RATIO=0.10
|
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
.env
|
Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /code
|
| 4 |
+
|
| 5 |
+
# Install system dependencies (needed for standard operations if any)
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
build-essential \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
COPY ./requirements.txt /code/requirements.txt
|
| 11 |
+
|
| 12 |
+
RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Hugging Face Spaces runs on port 7860
|
| 17 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,43 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: AIShoppingAssistance Server
|
| 3 |
+
emoji: π₯
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
| 11 |
+
|
| 12 |
+
## Current Directory Structure for the Server
|
| 13 |
+
```
|
| 14 |
+
hf_server/
|
| 15 |
+
βββ app/
|
| 16 |
+
β βββ __init__.py # Python package marker
|
| 17 |
+
β βββ main.py # Main entrypoint (FastAPI, Middlewares, Routers, and lifespans)
|
| 18 |
+
β βββ config.py # Server & model configuration constants
|
| 19 |
+
β βββ models/ # Pydantic schemas / request-response models
|
| 20 |
+
β β βββ __init__.py
|
| 21 |
+
β β βββ recipe.py # Pydantic model for recipes
|
| 22 |
+
β βββ services/ # Heavy logic & external database clients
|
| 23 |
+
β β βββ __init__.py
|
| 24 |
+
β β βββ http_client.py # Global HTTPX AsyncClient lifecycle
|
| 25 |
+
β β βββ detector.py # CLIP/ONNX model and embedding generation
|
| 26 |
+
β β βββ chroma.py # ChromaDB Searcher
|
| 27 |
+
β β βββ supabase.py # Supabase Querier
|
| 28 |
+
β βββ routes/ # API endpoints
|
| 29 |
+
β β βββ __init__.py
|
| 30 |
+
β β βββ health.py # health and root paths
|
| 31 |
+
β β βββ detection.py # product detection, embeddings, and gallery
|
| 32 |
+
β β βββ recipe.py # recipe generation and cart agent
|
| 33 |
+
β βββ agents/ # AI agents & parser logic
|
| 34 |
+
β βββ __init__.py
|
| 35 |
+
β βββ shopping_assistant_agent.py # Main conversational agentic loop
|
| 36 |
+
β βββ recipe_agent.py # Recipe agent orchestrator
|
| 37 |
+
β βββ tools/ # Agent tools
|
| 38 |
+
β βββ __init__.py
|
| 39 |
+
β βββ quantity_parser_tool.py # Quantity measurement parser
|
| 40 |
+
βββ requirements.txt
|
| 41 |
+
βββ Dockerfile # Updated entrypoint cmd
|
| 42 |
+
βββ run_local.sh
|
| 43 |
+
```
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# App package initialization
|
app/agents/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Agents package initialization
|
app/agents/missing_regulars_agent.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import statistics
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import List, Dict, Any
|
| 6 |
+
from groq import Groq
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
from app.services.supabase import SupabaseQuerier
|
| 10 |
+
|
| 11 |
+
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 12 |
+
dotenv_path = os.path.join(base_dir, ".env")
|
| 13 |
+
load_dotenv(dotenv_path=dotenv_path, override=True)
|
| 14 |
+
|
| 15 |
+
class MissingRegularsAgent:
|
| 16 |
+
def __init__(self):
|
| 17 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 18 |
+
if api_key:
|
| 19 |
+
api_key = api_key.replace("your_groq_api_key_here", "").strip()
|
| 20 |
+
print(f"[MissingRegularsAgent] GROQ API Key: {api_key}")
|
| 21 |
+
if not api_key:
|
| 22 |
+
print("[WARNING] GROQ_API_KEY not detected, using mock key")
|
| 23 |
+
api_key = "gsk_mock_key_placeholder"
|
| 24 |
+
self.client = Groq(api_key=api_key)
|
| 25 |
+
self.model = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
|
| 26 |
+
self.fallback_model = os.getenv("GROQ_FALLBACK_MODEL", "llama-3.3-70b-versatile")
|
| 27 |
+
self.supabase = SupabaseQuerier()
|
| 28 |
+
|
| 29 |
+
def _analyze_regularity(self, orders: List[Dict[str, Any]], current_cart_skus: List[str]) -> List[Dict[str, Any]]:
|
| 30 |
+
"""
|
| 31 |
+
Analyzes order history to find regular items missing from the current cart.
|
| 32 |
+
Applies a 3-layer filter: Frequency, Consistency (CV), and Timing (Due Date).
|
| 33 |
+
"""
|
| 34 |
+
if not orders:
|
| 35 |
+
return []
|
| 36 |
+
|
| 37 |
+
# 1. Extract dates and group by SKU
|
| 38 |
+
sku_history = {}
|
| 39 |
+
sku_metadata = {}
|
| 40 |
+
|
| 41 |
+
for order in orders:
|
| 42 |
+
try:
|
| 43 |
+
# The items column might be a JSON string or already parsed list
|
| 44 |
+
items_raw = order.get("items", [])
|
| 45 |
+
items = json.loads(items_raw) if isinstance(items_raw, str) else items_raw
|
| 46 |
+
|
| 47 |
+
# Parse created_at
|
| 48 |
+
created_at_str = order.get("created_at")
|
| 49 |
+
if not created_at_str:
|
| 50 |
+
continue
|
| 51 |
+
# Simple parsing assuming ISO format
|
| 52 |
+
created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00")).date()
|
| 53 |
+
|
| 54 |
+
for item in items:
|
| 55 |
+
# Extract SKU from details if id is not the SKU
|
| 56 |
+
details = item.get("details", "")
|
| 57 |
+
sku = item.get("id")
|
| 58 |
+
if "SKU: " in details:
|
| 59 |
+
sku = details.split("SKU: ")[1].split(" ")[0]
|
| 60 |
+
|
| 61 |
+
if not sku:
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
if sku not in sku_history:
|
| 65 |
+
sku_history[sku] = set()
|
| 66 |
+
sku_metadata[sku] = {
|
| 67 |
+
"sku": sku,
|
| 68 |
+
"name": item.get("name", "Unknown Item"),
|
| 69 |
+
"price": item.get("price", 0),
|
| 70 |
+
"imageUrl": item.get("imageUrl", "")
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
sku_history[sku].add(created_at)
|
| 74 |
+
|
| 75 |
+
except Exception as e:
|
| 76 |
+
print(f"[MissingRegularsAgent] Error parsing order: {e}")
|
| 77 |
+
continue
|
| 78 |
+
|
| 79 |
+
today = datetime.utcnow().date()
|
| 80 |
+
missing_regulars = []
|
| 81 |
+
|
| 82 |
+
# 2. Apply regularity logic
|
| 83 |
+
for sku, dates_set in sku_history.items():
|
| 84 |
+
dates = sorted(list(dates_set))
|
| 85 |
+
|
| 86 |
+
# Layer 1: Frequency Check (Must be bought on at least 3 distinct days)
|
| 87 |
+
if len(dates) < 3:
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
# Calculate gaps between purchases
|
| 91 |
+
gaps = [(dates[i] - dates[i-1]).days for i in range(1, len(dates))]
|
| 92 |
+
if not gaps:
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
avg_gap = statistics.mean(gaps)
|
| 96 |
+
|
| 97 |
+
# Layer 2: Consistency Check (CV <= 0.6)
|
| 98 |
+
if len(gaps) > 1:
|
| 99 |
+
std_dev = statistics.stdev(gaps)
|
| 100 |
+
cv = std_dev / avg_gap if avg_gap > 0 else 0
|
| 101 |
+
if cv > 0.6:
|
| 102 |
+
continue # Too erratic, not a consistent regular item
|
| 103 |
+
|
| 104 |
+
# Layer 3: Timing / Due Date Check
|
| 105 |
+
days_since_last = (today - dates[-1]).days
|
| 106 |
+
|
| 107 |
+
# If they bought it very recently, they don't need it yet.
|
| 108 |
+
# If days_since_last is close to or greater than avg_gap, they are due.
|
| 109 |
+
# We add a small buffer (e.g., -2 days) so we remind them slightly before they completely run out.
|
| 110 |
+
if days_since_last < (avg_gap - 2):
|
| 111 |
+
continue
|
| 112 |
+
|
| 113 |
+
# Final Filter: Is it already in the cart?
|
| 114 |
+
if sku in current_cart_skus:
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
# Passed all layers! It's a missing regular.
|
| 118 |
+
item_data = sku_metadata[sku]
|
| 119 |
+
item_data["frequency"] = len(dates)
|
| 120 |
+
item_data["avg_gap_days"] = round(avg_gap, 1)
|
| 121 |
+
item_data["last_bought_days_ago"] = days_since_last
|
| 122 |
+
|
| 123 |
+
missing_regulars.append(item_data)
|
| 124 |
+
|
| 125 |
+
# Sort by most frequently bought
|
| 126 |
+
missing_regulars.sort(key=lambda x: x["frequency"], reverse=True)
|
| 127 |
+
return missing_regulars
|
| 128 |
+
|
| 129 |
+
async def analyze_cart(self, user_id: str, current_cart: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 130 |
+
"""
|
| 131 |
+
Main entry point for the route.
|
| 132 |
+
Fetches history, finds missing regulars, and gets the LLM to write a friendly reminder.
|
| 133 |
+
"""
|
| 134 |
+
# Fetch history (last 90 days)
|
| 135 |
+
orders = await self.supabase.get_order_history(user_id=user_id, days=90)
|
| 136 |
+
|
| 137 |
+
# Extract current SKUs
|
| 138 |
+
current_skus = [item.get("sku") or item.get("id") for item in current_cart]
|
| 139 |
+
|
| 140 |
+
# Find missing regulars via Python logic
|
| 141 |
+
missing_items = self._analyze_regularity(orders, current_skus)
|
| 142 |
+
|
| 143 |
+
if not missing_items:
|
| 144 |
+
return {
|
| 145 |
+
"response_text": "",
|
| 146 |
+
"missing_regulars": []
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
# Format items for the LLM prompt
|
| 150 |
+
cart_str = ", ".join([item.get("name", "") for item in current_cart]) if current_cart else "Empty cart"
|
| 151 |
+
missing_str = ", ".join([item["name"] for item in missing_items])
|
| 152 |
+
|
| 153 |
+
# LLM writes the phrasing
|
| 154 |
+
prompt = f"""You are a helpful, friendly AI shopping assistant.
|
| 155 |
+
The user is currently reviewing their shopping cart.
|
| 156 |
+
Current cart contains: {cart_str}
|
| 157 |
+
|
| 158 |
+
Based on our deterministic analysis of their past 90 days of orders, they regularly buy these items every few weeks, but forgot to add them today:
|
| 159 |
+
Missing Regulars: {missing_str}
|
| 160 |
+
|
| 161 |
+
Write a very brief, friendly 1-2 sentence reminder suggesting they might want to add these to their cart before checking out.
|
| 162 |
+
Do not mention the "90 days" or the algorithm. Just be natural and helpful, like "I noticed you usually grab..." or "Don't forget your usual...".
|
| 163 |
+
Respond ONLY with the message text. No JSON, no extra formatting."""
|
| 164 |
+
|
| 165 |
+
try:
|
| 166 |
+
try:
|
| 167 |
+
completion = self.client.chat.completions.create(
|
| 168 |
+
model=self.model,
|
| 169 |
+
messages=[{"role": "user", "content": prompt}],
|
| 170 |
+
max_tokens=150,
|
| 171 |
+
temperature=0.4
|
| 172 |
+
)
|
| 173 |
+
except Exception as inner_e:
|
| 174 |
+
if self.fallback_model:
|
| 175 |
+
print(f"[MissingRegularsAgent] Main model {self.model} failed: {inner_e}. Falling back to {self.fallback_model}...")
|
| 176 |
+
completion = self.client.chat.completions.create(
|
| 177 |
+
model=self.fallback_model,
|
| 178 |
+
messages=[{"role": "user", "content": prompt}],
|
| 179 |
+
max_tokens=150,
|
| 180 |
+
temperature=0.4
|
| 181 |
+
)
|
| 182 |
+
else:
|
| 183 |
+
raise inner_e
|
| 184 |
+
response_text = completion.choices[0].message.content.strip()
|
| 185 |
+
except Exception as e:
|
| 186 |
+
print(f"[MissingRegularsAgent] LLM Generation failed completely: {e}")
|
| 187 |
+
response_text = "It looks like you might have forgotten a few of your regular items. Would you like to add them?"
|
| 188 |
+
|
| 189 |
+
return {
|
| 190 |
+
"response_text": response_text,
|
| 191 |
+
"missing_regulars": missing_items
|
| 192 |
+
}
|
app/agents/recipe_agent.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import re
|
| 4 |
+
from typing import Dict, Any, List
|
| 5 |
+
from groq import Groq
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from app.services.nutrition_service import NutritionService
|
| 8 |
+
from app.services.quantity_normalizer_service import QuantityNormalizerService
|
| 9 |
+
|
| 10 |
+
# Explicitly load .env file from the hf_server directory
|
| 11 |
+
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 12 |
+
dotenv_path = os.path.join(base_dir, ".env")
|
| 13 |
+
load_dotenv(dotenv_path=dotenv_path, override=True)
|
| 14 |
+
|
| 15 |
+
# Hard Category Guardrail: substrings that identify non-cooking items
|
| 16 |
+
# (household cleaners, air fresheners, baby formulas, health drinks, etc.)
|
| 17 |
+
# Items matching any of these in their name or slug are excluded from the
|
| 18 |
+
# inventory before the LLM ever sees them.
|
| 19 |
+
_NON_FOOD_SUBSTRINGS = [
|
| 20 |
+
"air freshener", "air-freshener", "car air freshener",
|
| 21 |
+
"room spray", "room-spray",
|
| 22 |
+
"cleaner", "disinfectant", "flushmatic",
|
| 23 |
+
"shampoo", "hair colour", "water bottle", "water-bottle",
|
| 24 |
+
"cerelac", "ceregrow", "lactogen", "similac",
|
| 25 |
+
"health drink", "health-drink", "horlicks", "bournvita",
|
| 26 |
+
"power pocket", "power-pocket",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
class RecipeAgent:
|
| 30 |
+
def __init__(self):
|
| 31 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 32 |
+
if api_key:
|
| 33 |
+
api_key = api_key.replace("your_groq_api_key_here", "").strip()
|
| 34 |
+
if not api_key:
|
| 35 |
+
print("[WARNING] GROQ_API_KEY not detected, using mock key")
|
| 36 |
+
api_key = "gsk_mock_key_placeholder_for_verification_only"
|
| 37 |
+
self.client = Groq(api_key=api_key)
|
| 38 |
+
self.model = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
|
| 39 |
+
self.nutrition_service = NutritionService()
|
| 40 |
+
self.quantity_normalizer = QuantityNormalizerService(
|
| 41 |
+
os.getenv("USDA_API_KEY")
|
| 42 |
+
)
|
| 43 |
+
self.fallback_model = os.getenv("GROQ_FALLBACK_MODEL", "llama-3.3-70b-versatile")
|
| 44 |
+
|
| 45 |
+
def _clean_dish_name(self, query: str) -> str:
|
| 46 |
+
q = str(query).lower().strip()
|
| 47 |
+
|
| 48 |
+
# Strip common punctuation
|
| 49 |
+
q = re.sub(r"[?.,!]", "", q)
|
| 50 |
+
|
| 51 |
+
# Regex replacement patterns for common recipe request prefixes
|
| 52 |
+
prefixes = [
|
| 53 |
+
r"\bgive me the recipe for a\b",
|
| 54 |
+
r"\bgive me the recipe for an\b",
|
| 55 |
+
r"\bgive me the recipe for\b",
|
| 56 |
+
r"\bgive me a recipe for\b",
|
| 57 |
+
r"\brecipe for a\b",
|
| 58 |
+
r"\brecipe for an\b",
|
| 59 |
+
r"\brecipe for\b",
|
| 60 |
+
r"\bhow to make a\b",
|
| 61 |
+
r"\bhow to make an\b",
|
| 62 |
+
r"\bhow to make\b",
|
| 63 |
+
r"\bhow to cook a\b",
|
| 64 |
+
r"\bhow to cook an\b",
|
| 65 |
+
r"\bhow to cook\b",
|
| 66 |
+
r"\bhow do i make a\b",
|
| 67 |
+
r"\bhow do i make an\b",
|
| 68 |
+
r"\bhow do i make\b",
|
| 69 |
+
r"\bhow do i cook a\b",
|
| 70 |
+
r"\bhow do i cook an\b",
|
| 71 |
+
r"\bhow do i cook\b",
|
| 72 |
+
r"\bi want to make a\b",
|
| 73 |
+
r"\bi want to make an\b",
|
| 74 |
+
r"\bi want to make\b",
|
| 75 |
+
r"\bi want a recipe for\b",
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
for pattern in prefixes:
|
| 79 |
+
q = re.sub(pattern, "", q).strip()
|
| 80 |
+
|
| 81 |
+
# Capitalize words to make it look premium
|
| 82 |
+
return q.title()
|
| 83 |
+
|
| 84 |
+
def _create_chat_completion(self, messages, max_tokens=1024, temperature=0.2, response_format=None):
|
| 85 |
+
try:
|
| 86 |
+
return self.client.chat.completions.create(
|
| 87 |
+
model=self.model,
|
| 88 |
+
messages=messages,
|
| 89 |
+
max_tokens=max_tokens,
|
| 90 |
+
temperature=temperature,
|
| 91 |
+
response_format=response_format
|
| 92 |
+
)
|
| 93 |
+
except Exception as e:
|
| 94 |
+
if self.fallback_model:
|
| 95 |
+
print(f"β οΈ [RECIPE AGENT FALLBACK] Main model {self.model} failed: {e}. Falling back to {self.fallback_model}...")
|
| 96 |
+
return self.client.chat.completions.create(
|
| 97 |
+
model=self.fallback_model,
|
| 98 |
+
messages=messages,
|
| 99 |
+
max_tokens=max_tokens,
|
| 100 |
+
temperature=temperature,
|
| 101 |
+
response_format=response_format
|
| 102 |
+
)
|
| 103 |
+
else:
|
| 104 |
+
raise e
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def generate(self, dish_query: str, servings: int) -> Dict[str, Any]:
|
| 108 |
+
"""
|
| 109 |
+
Generates a detailed recipe using Groq forced output structures.
|
| 110 |
+
"""
|
| 111 |
+
clean_dish = self._clean_dish_name(dish_query)
|
| 112 |
+
prompt = f"""Generate a detailed recipe for {clean_dish} for {servings} servings.
|
| 113 |
+
IMPORTANT:
|
| 114 |
+
- quantity must be a NUMBER only.
|
| 115 |
+
- Never include units inside quantity.
|
| 116 |
+
- Correct:
|
| 117 |
+
quantity: 1, unit: "cups"
|
| 118 |
+
quantity: 2, unit: "tablespoons"
|
| 119 |
+
quantity: 3, unit: "cloves"
|
| 120 |
+
- Incorrect:
|
| 121 |
+
quantity: "1 cup"
|
| 122 |
+
quantity: "2 tablespoons"
|
| 123 |
+
quantity: "3 cloves"
|
| 124 |
+
### CRITICAL INGREDIENT ISOLATION RULES:
|
| 125 |
+
|
| 126 |
+
1. Every ingredient must be a single USDA-searchable ingredient.
|
| 127 |
+
2. Never group multiple ingredients into one ingredient.
|
| 128 |
+
3. Never use parentheses in ingredient names.
|
| 129 |
+
4. Never include preparation details in ingredient names.
|
| 130 |
+
|
| 131 |
+
BAD:
|
| 132 |
+
- Vegetables (carrots, peas, cauliflower)
|
| 133 |
+
- Spices (cumin, coriander, turmeric)
|
| 134 |
+
- Rice (washed and soaked)
|
| 135 |
+
- Onion (thinly sliced)
|
| 136 |
+
|
| 137 |
+
GOOD:
|
| 138 |
+
- Mixed Vegetables
|
| 139 |
+
- Spices
|
| 140 |
+
- Rice
|
| 141 |
+
- Onion
|
| 142 |
+
- Garlic
|
| 143 |
+
- Ginger
|
| 144 |
+
|
| 145 |
+
Ingredient names must be simple, singular, USDA-searchable names.
|
| 146 |
+
Preparation details belong in recipe instructions, not ingredient names.
|
| 147 |
+
|
| 148 |
+
Return ONLY valid JSON in this exact format (no markdown strings, no code fences):
|
| 149 |
+
{{
|
| 150 |
+
"dish": "{clean_dish}",
|
| 151 |
+
"servings": {servings},
|
| 152 |
+
"instructions": ["step 1", "step 2"],
|
| 153 |
+
"ingredients": [
|
| 154 |
+
{{"name": "Ingredient Name", "quantity": "amount", "unit": "unit"}}
|
| 155 |
+
]
|
| 156 |
+
}}
|
| 157 |
+
|
| 158 |
+
CRITICAL RULE β Ingredient Isolation: Every single ingredient must be its own independent dictionary entry in the "ingredients" list. NEVER group multiple items together in a single line like "Spices (Turmeric, Salt, Chili Powder)". Break them down into separate entries: {{"name": "Turmeric Powder", "quantity": "1/2", "unit": "teaspoon"}}, {{"name": "Red Chili Powder", ...}}, and {{"name": "Salt", ...}}."""
|
| 159 |
+
|
| 160 |
+
messages = [
|
| 161 |
+
{
|
| 162 |
+
"role": "system",
|
| 163 |
+
"content": "You are an expert chef assistant. You must provide your output strictly formatted as a json object matching the structural schema requested."
|
| 164 |
+
},
|
| 165 |
+
{"role": "user", "content": prompt}
|
| 166 |
+
]
|
| 167 |
+
|
| 168 |
+
response = self._create_chat_completion(
|
| 169 |
+
messages=messages,
|
| 170 |
+
max_tokens=1024,
|
| 171 |
+
temperature=0.2,
|
| 172 |
+
response_format={"type": "json_object"}
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
content = response.choices[0].message.content or ""
|
| 176 |
+
content = content.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
return json.loads(content)
|
| 180 |
+
|
| 181 |
+
except json.JSONDecodeError:
|
| 182 |
+
return {
|
| 183 |
+
"raw_response": content,
|
| 184 |
+
"dish": clean_dish,
|
| 185 |
+
"servings": servings,
|
| 186 |
+
"instructions": [],
|
| 187 |
+
"ingredients": []
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
def _is_non_food(self, item: Dict[str, Any]) -> bool:
|
| 191 |
+
"""Category guardrail: check if an inventory item is a non-cooking item
|
| 192 |
+
(household cleaner, air freshener, baby food, health drink, etc.)"""
|
| 193 |
+
text = (item.get("name", "") + " " + item.get("slug", "")).lower()
|
| 194 |
+
return any(sub in text for sub in _NON_FOOD_SUBSTRINGS)
|
| 195 |
+
|
| 196 |
+
def _filter_relevant_inventory(self, parsed_ingredients: List[Dict[str, Any]], inventory_catalog: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 197 |
+
"""Pre-filter inventory to only items whose name/slug shares tokens with ingredient names.
|
| 198 |
+
Then apply Category Guardrail to drop non-cooking items entirely.
|
| 199 |
+
This prevents the LLM from seeing completely unrelated items (e.g. toilet cleaner
|
| 200 |
+
alongside spices) and hallucinating matches."""
|
| 201 |
+
candidates = []
|
| 202 |
+
seen = set()
|
| 203 |
+
|
| 204 |
+
# Sauce/condiment guardrail: prevent condiment products (sauce, ketchup, jam, spread)
|
| 205 |
+
# from matching raw ingredients that don't mention the condiment category.
|
| 206 |
+
# e.g. "Sweet Onion Sauce" must NOT match "Onion" β sauce != raw vegetable.
|
| 207 |
+
# "paste" is intentionally excluded since pastes (Ginger Garlic Paste) are valid ingredient matches.
|
| 208 |
+
sauce_keywords = {"sauce", "ketchup", "jam", "spread"}
|
| 209 |
+
|
| 210 |
+
for ing in parsed_ingredients:
|
| 211 |
+
ing_name = ing.get("name", "").lower().strip()
|
| 212 |
+
# Clean out common leakage remnants that survived parsing
|
| 213 |
+
ing_name = re.sub(r"\bas needed\b|\binch piece\b", "", ing_name).strip()
|
| 214 |
+
|
| 215 |
+
# Split into tokens: keep 3+ char words, plus short staples like "ghee"
|
| 216 |
+
ing_tokens = [t for t in ing_name.split() if len(t) >= 3 or t == "ghee"]
|
| 217 |
+
|
| 218 |
+
if not ing_tokens and not ing_name:
|
| 219 |
+
continue
|
| 220 |
+
|
| 221 |
+
for item in inventory_catalog:
|
| 222 |
+
# Category Guardrail: skip non-food items entirely
|
| 223 |
+
if self._is_non_food(item):
|
| 224 |
+
continue
|
| 225 |
+
|
| 226 |
+
item_name = item.get("name", "").lower()
|
| 227 |
+
item_slug = item.get("slug", "").lower()
|
| 228 |
+
|
| 229 |
+
# Sauce Guardrail: if the item is a condiment and the ingredient is not, skip
|
| 230 |
+
if any(sk in item_name for sk in sauce_keywords) and not any(sk in ing_name for sk in sauce_keywords):
|
| 231 |
+
continue
|
| 232 |
+
|
| 233 |
+
# Broad containment matching: token in name/slug, or full name in item name
|
| 234 |
+
if any(token in item_name or token in item_slug for token in ing_tokens) or ing_name in item_name:
|
| 235 |
+
key = item.get("sku") or item.get("slug", "")
|
| 236 |
+
if key not in seen:
|
| 237 |
+
seen.add(key)
|
| 238 |
+
candidates.append(item)
|
| 239 |
+
|
| 240 |
+
# If filtering removed everything, return what we have rather than
|
| 241 |
+
# randomly padding with unrelated food items (which causes hallucinated
|
| 242 |
+
# matches like "Quaker Oats" appearing in a pizza ingredient list).
|
| 243 |
+
return candidates
|
| 244 |
+
|
| 245 |
+
def match_inventory_with_ai(self, parsed_ingredients: List[Dict[str, Any]], inventory_catalog: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 246 |
+
"""
|
| 247 |
+
Uses Groq to intelligently map parsed ingredients to real store catalog items.
|
| 248 |
+
"""
|
| 249 |
+
filtered_catalog = self._filter_relevant_inventory(parsed_ingredients, inventory_catalog)
|
| 250 |
+
|
| 251 |
+
prompt = f"""
|
| 252 |
+
You are a strict retail inventory matching system. Your job is to look at each requested recipe ingredient's "name" field and find a semantically equivalent product from our store inventory.
|
| 253 |
+
|
| 254 |
+
Requested Recipe Ingredients (only the "name" field matters for matching):
|
| 255 |
+
{json.dumps(parsed_ingredients, indent=2)}
|
| 256 |
+
|
| 257 |
+
Our Store Inventory Catalog (only relevant products shown):
|
| 258 |
+
{json.dumps(filtered_catalog, indent=2)}
|
| 259 |
+
|
| 260 |
+
### CRITICAL MATCHING RULES:
|
| 261 |
+
1. The product MUST BE the actual ingredient, not just share a word. Examples of BAD matches you must NEVER do:
|
| 262 |
+
- Ingredient "Onions" β "Cream and Onion Chips" (chips are NOT onions)
|
| 263 |
+
- Ingredient "Rice" β "Cerelac Rice" (baby cereal is NOT cooking rice)
|
| 264 |
+
- Ingredient "Ginger" β "Chocolate Bar" (chocolate is NOT ginger)
|
| 265 |
+
- Ingredient "Garlic" β "Wheat Apple Baby Food" (baby food is NOT garlic)
|
| 266 |
+
- Ingredient "Oil" β "Tomato Ketchup" (ketchup is NOT oil)
|
| 267 |
+
- Ingredient "Spices" β "Air Freshener" (air freshener is NOT a spice)
|
| 268 |
+
|
| 269 |
+
2. Only match when the product IS the ingredient (e.g., "Oil" β "Groundnut Oil", "Milk" β "Standardised Milk", "Ginger" or "Garlic" β "Ginger Garlic Paste").
|
| 270 |
+
|
| 271 |
+
3. If no truly matching product exists in inventory, you MUST return "sku": "UNKNOWN", "price_rupees": 0, "slug": the ingredient name as a lowercase-slug, "name": the ingredient name. In this case, you should also look at the inventory catalog and provide up to 3 possible close substitutes that are available in the inventory under the "substitutes" key. For example, if "Heavy Cream" is requested and not found, you can suggest "Standardised Milk" or "Butter" as substitutes. If no substitutes are reasonable, return an empty list.
|
| 272 |
+
|
| 273 |
+
4. The "required_quantity" field should use the ingredient's "quantity" value from the requested ingredient (or "raw_input").
|
| 274 |
+
|
| 275 |
+
### Expected Output Format:
|
| 276 |
+
Return a strict JSON object with a single key "results" containing an array of matched items:
|
| 277 |
+
{{
|
| 278 |
+
"results": [
|
| 279 |
+
{{
|
| 280 |
+
"sku": "string",
|
| 281 |
+
"slug": "string",
|
| 282 |
+
"name": "string",
|
| 283 |
+
"price_rupees": number,
|
| 284 |
+
"required_quantity": "string",
|
| 285 |
+
"substitutes": [
|
| 286 |
+
{{
|
| 287 |
+
"sku": "string",
|
| 288 |
+
"name": "string",
|
| 289 |
+
"price_rupees": number,
|
| 290 |
+
"thumbnail_url": "string"
|
| 291 |
+
}}
|
| 292 |
+
]
|
| 293 |
+
}}
|
| 294 |
+
]
|
| 295 |
+
}}
|
| 296 |
+
"""
|
| 297 |
+
|
| 298 |
+
completion = self._create_chat_completion(
|
| 299 |
+
messages=[
|
| 300 |
+
{
|
| 301 |
+
"role": "system",
|
| 302 |
+
"content": "You are a strict inventory matching assistant. Never match an ingredient to a product that is not the actual ingredient. Only match when the product IS the ingredient (e.g. oil to oil, milk to milk). If no match exists, return UNKNOWN. If an ingredient is UNKNOWN, identify up to 3 suitable substitute items from the inventory catalog and place them in the \"substitutes\" list. Return only a valid JSON object with a \"results\" key."
|
| 303 |
+
},
|
| 304 |
+
{
|
| 305 |
+
"role": "user",
|
| 306 |
+
"content": prompt
|
| 307 |
+
}
|
| 308 |
+
],
|
| 309 |
+
response_format={"type": "json_object"}
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
try:
|
| 313 |
+
result = json.loads(completion.choices[0].message.content)
|
| 314 |
+
# Handle if AI nests it inside a root dictionary key wrapper
|
| 315 |
+
if isinstance(result, dict):
|
| 316 |
+
for key in ["matches", "items", "data", "ingredients", "results"]:
|
| 317 |
+
if key in result:
|
| 318 |
+
return result[key]
|
| 319 |
+
return list(result.values())[0] if result else []
|
| 320 |
+
return result
|
| 321 |
+
except Exception:
|
| 322 |
+
return []
|
| 323 |
+
|
| 324 |
+
async def generate_and_match_recipe(self, dish_query: str, servings: int, inventory_catalog: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 325 |
+
"""
|
| 326 |
+
Generates a detailed recipe and matches its ingredients to the store catalog in a single LLM call.
|
| 327 |
+
"""
|
| 328 |
+
clean_dish = self._clean_dish_name(dish_query)
|
| 329 |
+
food_catalog = [item for item in inventory_catalog if not self._is_non_food(item)]
|
| 330 |
+
|
| 331 |
+
prompt = f"""Generate a detailed recipe for {clean_dish} for {servings} servings, and match the required ingredients to our store's inventory catalog.
|
| 332 |
+
|
| 333 |
+
Available Store Inventory Catalog (SKUs and Names):
|
| 334 |
+
{json.dumps(food_catalog, indent=2)}
|
| 335 |
+
|
| 336 |
+
### CRITICAL MATCHING RULES:
|
| 337 |
+
1. Every single recipe ingredient must be matched to a product in the catalog.
|
| 338 |
+
2. The product MUST BE the actual ingredient, not just share a word. Examples of BAD matches:
|
| 339 |
+
- "Onions" -> "Cream and Onion Chips" (chips are NOT onions)
|
| 340 |
+
- "Rice" -> "Cerelac Rice" (baby cereal is NOT cooking rice)
|
| 341 |
+
- "Garlic" -> "Wheat Apple Baby Food"
|
| 342 |
+
- "Oil" -> "Tomato Ketchup"
|
| 343 |
+
- "Flour" or any pizza ingredient -> "Quaker Oats" (oats are NOT a pizza ingredient)
|
| 344 |
+
- "Cheese" or "Dough" -> "Oats" or "Cereal" (breakfast items are NOT pizza/bread ingredients)
|
| 345 |
+
3. Only match when the product IS the ingredient (e.g. "Oil" -> "Gold Winner Refined Sunflower Oil" or "Idhayam Mantra Groundnut Oil", "Milk" -> "Amul Gold Standardised Milk", "Ginger" or "Garlic" -> "Aachi Ginger Garlic Paste").
|
| 346 |
+
4. If no truly matching product exists in the catalog, you MUST return "sku": "UNKNOWN", "price_rupees": 0, "name": the ingredient name, and "slug": the ingredient name as a lowercase-slug.
|
| 347 |
+
|
| 348 |
+
For UNKNOWN items, only provide substitutes if they are the same type of cooking ingredient.
|
| 349 |
+
|
| 350 |
+
Examples:
|
| 351 |
+
- Oil -> another oil product
|
| 352 |
+
- Milk -> another milk product
|
| 353 |
+
- Ginger Garlic Paste -> another cooking paste
|
| 354 |
+
- Rice -> another rice product
|
| 355 |
+
|
| 356 |
+
Never suggest cereals, chocolates, chips, sweets, beverages, breakfast foods, baby foods, or instant noodles as substitutes for vegetables, rice, spices, meat, dairy, flour, dough, or cooking ingredients.
|
| 357 |
+
|
| 358 |
+
If no closely related cooking ingredient exists in the catalog, return an empty list for "substitutes".
|
| 359 |
+
### CRITICAL INGREDIENT ISOLATION RULES:
|
| 360 |
+
|
| 361 |
+
- Every ingredient must be a single USDA-searchable ingredient.
|
| 362 |
+
- Never group multiple ingredients into one ingredient.
|
| 363 |
+
- Never use parentheses in ingredient names.
|
| 364 |
+
- Never include preparation details in ingredient names.
|
| 365 |
+
|
| 366 |
+
BAD:
|
| 367 |
+
- Vegetables (carrots, peas, cauliflower)
|
| 368 |
+
- Spices (cumin, coriander, turmeric)
|
| 369 |
+
- Rice (washed and soaked)
|
| 370 |
+
- Onion (thinly sliced)
|
| 371 |
+
|
| 372 |
+
GOOD:
|
| 373 |
+
- Mixed Vegetables
|
| 374 |
+
- Spices
|
| 375 |
+
- Rice
|
| 376 |
+
- Onion
|
| 377 |
+
- Garlic
|
| 378 |
+
- Ginger
|
| 379 |
+
|
| 380 |
+
Ingredient names must be simple USDA-searchable names.
|
| 381 |
+
Preparation details belong in recipe instructions, not ingredient names.
|
| 382 |
+
|
| 383 |
+
IMPORTANT:
|
| 384 |
+
- quantity must be a NUMBER only.
|
| 385 |
+
- Never include units inside quantity.
|
| 386 |
+
|
| 387 |
+
Correct:
|
| 388 |
+
quantity: 2, unit: "cups"
|
| 389 |
+
quantity: 1, unit: "teaspoon"
|
| 390 |
+
|
| 391 |
+
Incorrect:
|
| 392 |
+
quantity: "2 cups"
|
| 393 |
+
quantity: "1 teaspoon"
|
| 394 |
+
Return ONLY valid JSON in this exact format (no markdown strings, no code fences):
|
| 395 |
+
{{
|
| 396 |
+
"dish": "{clean_dish}",
|
| 397 |
+
"servings": {servings},
|
| 398 |
+
"instructions": ["step 1", "step 2"],
|
| 399 |
+
"ingredients": [
|
| 400 |
+
{{
|
| 401 |
+
"name": "Ingredient Name",
|
| 402 |
+
"quantity": 1,
|
| 403 |
+
"unit": "unit",
|
| 404 |
+
"sku": "SKU_CODE_IF_MATCHED_OR_UNKNOWN",
|
| 405 |
+
"slug": "product-slug",
|
| 406 |
+
"price_rupees": 0.0,
|
| 407 |
+
"substitutes": [
|
| 408 |
+
{{
|
| 409 |
+
"sku": "string",
|
| 410 |
+
"name": "string",
|
| 411 |
+
"price_rupees": 0.0
|
| 412 |
+
}}
|
| 413 |
+
]
|
| 414 |
+
}}
|
| 415 |
+
]
|
| 416 |
+
}}
|
| 417 |
+
"""
|
| 418 |
+
|
| 419 |
+
messages = [
|
| 420 |
+
{
|
| 421 |
+
"role": "system",
|
| 422 |
+
"content": "You are an expert chef and a strict retail inventory matching system. You must generate recipes and match the ingredients strictly to the available catalog products, returning only a valid JSON object matching the requested schema."
|
| 423 |
+
},
|
| 424 |
+
{"role": "user", "content": prompt}
|
| 425 |
+
]
|
| 426 |
+
|
| 427 |
+
response = self._create_chat_completion(
|
| 428 |
+
messages=messages,
|
| 429 |
+
max_tokens=1536,
|
| 430 |
+
temperature=0.2,
|
| 431 |
+
response_format={"type": "json_object"}
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
content = response.choices[0].message.content or ""
|
| 435 |
+
content = content.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
| 436 |
+
|
| 437 |
+
try:
|
| 438 |
+
result = json.loads(content)
|
| 439 |
+
print("\n===== RAW RECIPE INGREDIENTS =====")
|
| 440 |
+
for x in result.get("ingredients", []):
|
| 441 |
+
print(x)
|
| 442 |
+
print("=================================\n")
|
| 443 |
+
async with self.quantity_normalizer as normalizer:
|
| 444 |
+
normalized_ingredients, skipped = (
|
| 445 |
+
await normalizer.normalize_ingredients(
|
| 446 |
+
result.get("ingredients", [])
|
| 447 |
+
)
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
print("NORMALIZED:", normalized_ingredients)
|
| 451 |
+
print("SKIPPED:", skipped)
|
| 452 |
+
|
| 453 |
+
nutrition = await self.nutrition_service.calculate_recipe_nutrition(
|
| 454 |
+
normalized_ingredients,
|
| 455 |
+
result.get("servings", servings),
|
| 456 |
+
)
|
| 457 |
+
print("NUTRITION RESULT:", nutrition)
|
| 458 |
+
|
| 459 |
+
result["nutrition"] = nutrition
|
| 460 |
+
|
| 461 |
+
return result
|
| 462 |
+
except json.JSONDecodeError:
|
| 463 |
+
return {
|
| 464 |
+
"dish": clean_dish,
|
| 465 |
+
"servings": servings,
|
| 466 |
+
"instructions": [],
|
| 467 |
+
"ingredients": []
|
| 468 |
+
}
|
app/agents/recipe_parser.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
class RecipeParser:
|
| 4 |
+
BAD_WORDS = [
|
| 5 |
+
"facebook",
|
| 6 |
+
"instagram",
|
| 7 |
+
"youtube",
|
| 8 |
+
"pinterest",
|
| 9 |
+
"twitter",
|
| 10 |
+
"share",
|
| 11 |
+
"recipe",
|
| 12 |
+
"faq",
|
| 13 |
+
"faqs",
|
| 14 |
+
"tips",
|
| 15 |
+
"comments",
|
| 16 |
+
"jump",
|
| 17 |
+
"about"
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
def _clean_text(self, text: str) -> str:
|
| 21 |
+
text = re.sub(r'\[(.*?)\]\([^)]*\)', r'\1', text)
|
| 22 |
+
text = re.sub(r'\*\*|__|~~', '', text)
|
| 23 |
+
text = text.replace('β’', '-').strip()
|
| 24 |
+
text = re.sub(r'\s+', ' ', text)
|
| 25 |
+
return text.strip()
|
| 26 |
+
|
| 27 |
+
def _is_markdown_link(self, text: str) -> bool:
|
| 28 |
+
return bool(re.match(r'^\[.*\]\(.*\)$', text))
|
| 29 |
+
|
| 30 |
+
def _should_skip(self, text: str) -> bool:
|
| 31 |
+
cleaned = self._clean_text(text).lower()
|
| 32 |
+
if not cleaned:
|
| 33 |
+
return True
|
| 34 |
+
if self._is_markdown_link(cleaned):
|
| 35 |
+
return True
|
| 36 |
+
if any(word in cleaned for word in self.BAD_WORDS):
|
| 37 |
+
return True
|
| 38 |
+
return False
|
| 39 |
+
|
| 40 |
+
def extract_servings(self, markdown: str) -> int:
|
| 41 |
+
"""
|
| 42 |
+
Extract serving count from recipe markdown.
|
| 43 |
+
|
| 44 |
+
Looks for patterns like:
|
| 45 |
+
- Serves 4
|
| 46 |
+
- Servings: 4
|
| 47 |
+
- Yield: 4
|
| 48 |
+
- Makes 4 servings
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
markdown (str): Recipe markdown content
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
int: Number of servings, defaults to 1 if not found
|
| 55 |
+
"""
|
| 56 |
+
if not markdown:
|
| 57 |
+
return 1
|
| 58 |
+
|
| 59 |
+
# Search in the first 2000 characters to find metadata
|
| 60 |
+
text = markdown[:2000].lower()
|
| 61 |
+
|
| 62 |
+
# Pattern 1: "serves 4" or "serve 4"
|
| 63 |
+
match = re.search(r'serves?\s+(\d+)', text)
|
| 64 |
+
if match:
|
| 65 |
+
return int(match.group(1))
|
| 66 |
+
|
| 67 |
+
# Pattern 2: "servings: 4" or "serving: 4"
|
| 68 |
+
match = re.search(r'servings?\s*:\s*(\d+)', text)
|
| 69 |
+
if match:
|
| 70 |
+
return int(match.group(1))
|
| 71 |
+
|
| 72 |
+
# Pattern 3: "yield: 4"
|
| 73 |
+
match = re.search(r'yields?\s*:\s*(\d+)', text)
|
| 74 |
+
if match:
|
| 75 |
+
return int(match.group(1))
|
| 76 |
+
|
| 77 |
+
# Pattern 4: "makes 4 servings" or "make 4 servings"
|
| 78 |
+
match = re.search(r'makes?\s+(\d+)', text)
|
| 79 |
+
if match:
|
| 80 |
+
return int(match.group(1))
|
| 81 |
+
|
| 82 |
+
return 1
|
| 83 |
+
|
| 84 |
+
def parse(self, markdown: str):
|
| 85 |
+
ingredients = []
|
| 86 |
+
instructions = []
|
| 87 |
+
|
| 88 |
+
for line in markdown.splitlines():
|
| 89 |
+
raw = line.strip()
|
| 90 |
+
if not raw:
|
| 91 |
+
continue
|
| 92 |
+
|
| 93 |
+
normalized = self._clean_text(raw)
|
| 94 |
+
if not normalized or self._should_skip(raw):
|
| 95 |
+
continue
|
| 96 |
+
|
| 97 |
+
if re.match(r'^(ingredients?|shopping list)\s*:?', normalized, re.IGNORECASE):
|
| 98 |
+
continue
|
| 99 |
+
|
| 100 |
+
if re.match(r'^[-*]\s+', raw):
|
| 101 |
+
item = re.sub(r'^[-*]\s+', '', raw)
|
| 102 |
+
item = self._clean_text(item)
|
| 103 |
+
if item and not self._should_skip(item):
|
| 104 |
+
ingredients.append(item)
|
| 105 |
+
|
| 106 |
+
elif re.match(r'^\d+[.)]\s+', raw):
|
| 107 |
+
item = re.sub(r'^\d+[.)]\s+', '', raw)
|
| 108 |
+
item = self._clean_text(item)
|
| 109 |
+
if item and not self._should_skip(item):
|
| 110 |
+
instructions.append(item)
|
| 111 |
+
|
| 112 |
+
return {
|
| 113 |
+
"ingredients": ingredients,
|
| 114 |
+
"instructions": instructions
|
| 115 |
+
}
|
app/agents/shopping_assistant_agent.py
ADDED
|
@@ -0,0 +1,1052 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import asyncio
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
from groq import Groq
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
from app.agents.recipe_agent import RecipeAgent
|
| 8 |
+
from app.agents.tools.quantity_parser_tool import QuantityParserTool
|
| 9 |
+
from app.services.nutrition_service import NutritionService
|
| 10 |
+
|
| 11 |
+
# Explicitly load .env file from the hf_server directory
|
| 12 |
+
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 13 |
+
dotenv_path = os.path.join(base_dir, ".env")
|
| 14 |
+
load_dotenv(dotenv_path=dotenv_path, override=True)
|
| 15 |
+
|
| 16 |
+
from app.utils.cart_state import live_cart_memory
|
| 17 |
+
|
| 18 |
+
# βββ UPDATED REAL TOOL FUNCTION βββ
|
| 19 |
+
def execute_database_cart_addition(user_id: str, sku: str, quantity: int) -> bool:
|
| 20 |
+
"""
|
| 21 |
+
Directly writes a persistent modification entry to your application's
|
| 22 |
+
active shopping cart storage cache layer.
|
| 23 |
+
"""
|
| 24 |
+
success = live_cart_memory.add_item(user_id=user_id, sku=sku, quantity=quantity)
|
| 25 |
+
if success:
|
| 26 |
+
print(f"πΎ [STATE COMMIT] User: '{user_id}' | SKU: '{sku}' successfully written to memory.")
|
| 27 |
+
return success
|
| 28 |
+
|
| 29 |
+
def execute_database_cart_removal(user_id: str, sku: str, quantity: int) -> bool:
|
| 30 |
+
"""
|
| 31 |
+
Directly writes a persistent modification entry to remove or decrement a SKU in the cart.
|
| 32 |
+
"""
|
| 33 |
+
success = live_cart_memory.remove_item(user_id=user_id, sku=sku, quantity=quantity)
|
| 34 |
+
if success:
|
| 35 |
+
print(f"πΎ [STATE REMOVE] User: '{user_id}' | SKU: '{sku}' successfully removed/decremented from memory.")
|
| 36 |
+
return success
|
| 37 |
+
|
| 38 |
+
# βββ ACTIVE REGISTRY MANDATORY HOOK βββ
|
| 39 |
+
ACTIVE_CART_TOOLS_REGISTRY = {
|
| 40 |
+
"add_to_cart": execute_database_cart_addition,
|
| 41 |
+
"remove_from_cart": execute_database_cart_removal
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
def format_ingredient_quantity(quantity: Any, unit: Any) -> str:
|
| 45 |
+
"""
|
| 46 |
+
Formats the quantity and unit of an ingredient safely, avoiding duplication
|
| 47 |
+
such as "1 cup cup" or "1 cup cup flour".
|
| 48 |
+
"""
|
| 49 |
+
qty_str = str(quantity or "").strip()
|
| 50 |
+
unit_str = str(unit or "").strip()
|
| 51 |
+
|
| 52 |
+
if not unit_str:
|
| 53 |
+
return qty_str
|
| 54 |
+
if not qty_str:
|
| 55 |
+
return unit_str
|
| 56 |
+
|
| 57 |
+
# Check if the unit string is already present in quantity or vice versa
|
| 58 |
+
if unit_str.lower() in qty_str.lower():
|
| 59 |
+
return qty_str
|
| 60 |
+
if qty_str.lower() in unit_str.lower():
|
| 61 |
+
return unit_str
|
| 62 |
+
|
| 63 |
+
# Word-level comparison to prevent e.g. "1 cup" and "cups" -> "1 cup cups"
|
| 64 |
+
qty_words = qty_str.split()
|
| 65 |
+
if qty_words:
|
| 66 |
+
last_word = qty_words[-1]
|
| 67 |
+
|
| 68 |
+
def clean_word(w):
|
| 69 |
+
w = w.lower().strip(".,() ")
|
| 70 |
+
if w.endswith("es"):
|
| 71 |
+
w = w[:-2]
|
| 72 |
+
elif w.endswith("s"):
|
| 73 |
+
w = w[:-1]
|
| 74 |
+
return w
|
| 75 |
+
|
| 76 |
+
if clean_word(last_word) == clean_word(unit_str):
|
| 77 |
+
return qty_str
|
| 78 |
+
|
| 79 |
+
return f"{qty_str} {unit_str}"
|
| 80 |
+
|
| 81 |
+
def _parse_qty(qty_str: Any) -> int:
|
| 82 |
+
"""
|
| 83 |
+
Parses a human-readable quantity string into an integer for cart additions.
|
| 84 |
+
Examples: "2 cans" -> 2, "1.5 cups" -> 2, "3" -> 3, "half" -> 1, "" -> 1
|
| 85 |
+
"""
|
| 86 |
+
import re
|
| 87 |
+
if not qty_str:
|
| 88 |
+
return 1
|
| 89 |
+
s = str(qty_str).strip().lower()
|
| 90 |
+
# Handle textual fractions
|
| 91 |
+
word_map = {"half": 1, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5}
|
| 92 |
+
for word, val in word_map.items():
|
| 93 |
+
if s.startswith(word):
|
| 94 |
+
return val
|
| 95 |
+
match = re.match(r"(\d+(?:\.\d+)?)", s)
|
| 96 |
+
if match:
|
| 97 |
+
return max(1, round(float(match.group(1))))
|
| 98 |
+
return 1
|
| 99 |
+
|
| 100 |
+
def clean_ingredient_name(name: Any, unit: Any) -> str:
|
| 101 |
+
"""
|
| 102 |
+
Cleans the ingredient name by removing any prepended unit words.
|
| 103 |
+
E.g. name="cups All-purpose flour", unit="cups" -> "All-purpose flour"
|
| 104 |
+
E.g. name="teaspoons Active dry yeast", unit="teaspoons" -> "Active dry yeast"
|
| 105 |
+
"""
|
| 106 |
+
name_str = str(name or "").strip()
|
| 107 |
+
unit_str = str(unit or "").strip()
|
| 108 |
+
if not name_str or not unit_str:
|
| 109 |
+
return name_str
|
| 110 |
+
|
| 111 |
+
name_words = name_str.split()
|
| 112 |
+
if not name_words:
|
| 113 |
+
return name_str
|
| 114 |
+
|
| 115 |
+
first_word = name_words[0]
|
| 116 |
+
|
| 117 |
+
# Exact word match (case-insensitive)
|
| 118 |
+
if first_word.lower() == unit_str.lower():
|
| 119 |
+
cleaned = " ".join(name_words[1:]).strip()
|
| 120 |
+
if cleaned.lower().startswith("of "):
|
| 121 |
+
cleaned = cleaned[3:].strip()
|
| 122 |
+
return cleaned
|
| 123 |
+
|
| 124 |
+
# Singular/plural matched word comparison
|
| 125 |
+
def clean_word(w):
|
| 126 |
+
w = w.lower().strip(".,() ")
|
| 127 |
+
if w.endswith("es"):
|
| 128 |
+
w = w[:-2]
|
| 129 |
+
elif w.endswith("s"):
|
| 130 |
+
w = w[:-1]
|
| 131 |
+
return w
|
| 132 |
+
|
| 133 |
+
if clean_word(first_word) == clean_word(unit_str):
|
| 134 |
+
cleaned = " ".join(name_words[1:]).strip()
|
| 135 |
+
if cleaned.lower().startswith("of "):
|
| 136 |
+
cleaned = cleaned[3:].strip()
|
| 137 |
+
return cleaned
|
| 138 |
+
|
| 139 |
+
return name_str
|
| 140 |
+
|
| 141 |
+
class ShoppingAssistantAgent:
|
| 142 |
+
def __init__(self):
|
| 143 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 144 |
+
if api_key:
|
| 145 |
+
api_key = api_key.replace("your_groq_api_key_here", "").strip()
|
| 146 |
+
if not api_key:
|
| 147 |
+
print("[WARNING] GROQ_API_KEY not detected in environment, using mock key to prevent startup crash")
|
| 148 |
+
api_key = "gsk_mock_key_placeholder_for_verification_only"
|
| 149 |
+
self.client = Groq(api_key=api_key)
|
| 150 |
+
self.model = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
|
| 151 |
+
self.fallback_model = os.getenv("GROQ_FALLBACK_MODEL", "llama-3.3-70b-versatile")
|
| 152 |
+
self.recipe_agent = RecipeAgent()
|
| 153 |
+
self.quantity_parser = QuantityParserTool()
|
| 154 |
+
self.nutrition_service = NutritionService()
|
| 155 |
+
self.inventory = self._load_inventory()
|
| 156 |
+
|
| 157 |
+
def _load_inventory(self) -> List[Dict[str, Any]]:
|
| 158 |
+
"""Loads the store inventory catalog from candidate locations."""
|
| 159 |
+
base_dir = os.path.dirname(os.path.abspath(__file__))
|
| 160 |
+
candidates = [
|
| 161 |
+
"/workspaces/AIShoppingAssistance/inventory.json",
|
| 162 |
+
os.path.abspath(os.path.join(base_dir, "inventory.json")),
|
| 163 |
+
os.path.abspath(os.path.join(base_dir, "..", "inventory.json")),
|
| 164 |
+
os.path.abspath(os.path.join(base_dir, "..", "..", "inventory.json")),
|
| 165 |
+
os.path.abspath(os.path.join(base_dir, "..", "..", "..", "inventory.json")),
|
| 166 |
+
os.path.abspath(os.path.join(base_dir, "..", "..", "..", "..", "inventory.json")),
|
| 167 |
+
os.path.abspath("inventory.json"),
|
| 168 |
+
os.path.abspath("../inventory.json"),
|
| 169 |
+
]
|
| 170 |
+
|
| 171 |
+
inventory_path = None
|
| 172 |
+
for path in candidates:
|
| 173 |
+
if os.path.exists(path):
|
| 174 |
+
inventory_path = path
|
| 175 |
+
break
|
| 176 |
+
|
| 177 |
+
if not inventory_path:
|
| 178 |
+
inventory_path = os.path.abspath(os.path.join(base_dir, "..", "..", "inventory.json"))
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
with open(inventory_path, "r") as f:
|
| 182 |
+
data = json.load(f)
|
| 183 |
+
return data.get("items", data) if isinstance(data, dict) else data
|
| 184 |
+
except Exception as e:
|
| 185 |
+
print(f"β οΈ [WARNING] Failed to load inventory database catalog from {inventory_path}: {e}")
|
| 186 |
+
return []
|
| 187 |
+
|
| 188 |
+
# ββ Stopwords to strip before keyword matching ββ
|
| 189 |
+
_STOP_WORDS = {
|
| 190 |
+
"a", "an", "the", "is", "are", "do", "you", "have", "has", "me", "my",
|
| 191 |
+
"i", "to", "of", "for", "in", "on", "at", "and", "or", "but", "can",
|
| 192 |
+
"it", "this", "that", "what", "how", "where", "which", "with", "your",
|
| 193 |
+
"add", "get", "show", "give", "want", "buy", "some", "any", "please",
|
| 194 |
+
"tell", "like", "make", "under", "over", "than", "more", "less", "much",
|
| 195 |
+
"many", "very", "just", "also", "from", "about", "would", "could", "should",
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
def _prefetch_relevant_items(self, query: str) -> List[Dict[str, Any]]:
|
| 199 |
+
"""
|
| 200 |
+
Regex-extract meaningful keywords from the user query, run token
|
| 201 |
+
matching against the inventory, and return the top matches with full
|
| 202 |
+
details (name, SKU, price). Called before the first LLM request so
|
| 203 |
+
the LLM can answer price/detail questions without an extra
|
| 204 |
+
search_inventory round-trip.
|
| 205 |
+
"""
|
| 206 |
+
import re
|
| 207 |
+
|
| 208 |
+
# ββ Category-word β inventory-token expansion ββββββββββββββββββββββ
|
| 209 |
+
_CATEGORY_MAP = {
|
| 210 |
+
"snack": ["chocolate", "chips", "biscuit", "cookie", "wafer", "bar", "candy", "cracker"],
|
| 211 |
+
"snacks": ["chocolate", "chips", "biscuit", "cookie", "wafer", "bar", "candy", "cracker"],
|
| 212 |
+
"drink": ["drink", "juice", "milk", "water", "coffee", "tea", "energy"],
|
| 213 |
+
"drinks": ["drink", "juice", "milk", "water", "coffee", "tea", "energy"],
|
| 214 |
+
"noodle": ["noodles", "maggi", "instant", "cuppa"],
|
| 215 |
+
"noodles": ["noodles", "maggi", "instant", "cuppa"],
|
| 216 |
+
"breakfast": ["cereal", "oats", "milk", "bread", "egg", "muesli"],
|
| 217 |
+
"chocolate": ["chocolate", "cocoa", "dark"],
|
| 218 |
+
"biscuit": ["biscuit", "cookie", "digestive", "cream"],
|
| 219 |
+
"biscuits": ["biscuit", "cookie", "digestive", "cream"],
|
| 220 |
+
"chips": ["chips", "crisps", "namkeen", "rings", "puffs"],
|
| 221 |
+
"sauce": ["sauce", "ketchup", "chutney", "paste"],
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
# ββ Extract price ceiling from query (e.g. "under βΉ50", "below 100") ββ
|
| 225 |
+
price_ceiling = None
|
| 226 |
+
price_match = re.search(r"(?:under|below|less\s+than|within)\s*[βΉrs\.]*\s*(\d+)", query.lower())
|
| 227 |
+
if price_match:
|
| 228 |
+
price_ceiling = int(price_match.group(1))
|
| 229 |
+
|
| 230 |
+
# Lowercase, strip punctuation, tokenise
|
| 231 |
+
cleaned = re.sub(r"[^\w\s]", " ", query.lower())
|
| 232 |
+
raw_tokens = [t for t in cleaned.split() if len(t) >= 3 and t not in self._STOP_WORDS]
|
| 233 |
+
|
| 234 |
+
# Expand category words
|
| 235 |
+
expanded_tokens = list(raw_tokens)
|
| 236 |
+
for t in raw_tokens:
|
| 237 |
+
if t in _CATEGORY_MAP:
|
| 238 |
+
expanded_tokens.extend(_CATEGORY_MAP[t])
|
| 239 |
+
|
| 240 |
+
if not expanded_tokens:
|
| 241 |
+
return []
|
| 242 |
+
|
| 243 |
+
matches: List[tuple] = []
|
| 244 |
+
for item in self.inventory:
|
| 245 |
+
item_name = item.get("name", "").lower()
|
| 246 |
+
item_slug = item.get("slug", "").lower()
|
| 247 |
+
item_tokens = set(item_name.split() + item_slug.split("-"))
|
| 248 |
+
score = sum(1 for t in expanded_tokens if t in item_tokens)
|
| 249 |
+
# Exact substring bonus for original (non-expanded) tokens
|
| 250 |
+
for t in raw_tokens:
|
| 251 |
+
if t in item_name:
|
| 252 |
+
score += 2
|
| 253 |
+
if score > 0:
|
| 254 |
+
matches.append((item, score))
|
| 255 |
+
|
| 256 |
+
matches.sort(key=lambda x: x[1], reverse=True)
|
| 257 |
+
top = [item for item, _ in matches[:20]]
|
| 258 |
+
|
| 259 |
+
# Apply price filter if detected
|
| 260 |
+
if price_ceiling is not None:
|
| 261 |
+
top = [it for it in top if it.get("price_rupees", 9999) <= price_ceiling]
|
| 262 |
+
|
| 263 |
+
return top[:12]
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ββ Regex-based recipe intent classifier (no LLM needed) ββββββββββββββ
|
| 267 |
+
_RECIPE_PATTERNS = [
|
| 268 |
+
r"\b(recipe|recipes)\b",
|
| 269 |
+
r"\bhow\s+(?:do\s+(?:i|we)|to|can\s+(?:i|we))\s+(?:make|cook|prepare|bake|fry|boil|roast|grill)\b",
|
| 270 |
+
r"\b(?:steps?|instructions?|procedure)\s+(?:to|for)\s+(?:make|cook|prepare)\b",
|
| 271 |
+
r"\b(?:make|cook|prepare|bake)\s+(?:me\s+)?(?:a|an|some)?\s*\w+\s+(?:dish|meal|curry|biryani|dosa|roti|bread|cake|soup|salad|pasta|noodle)\b",
|
| 272 |
+
r"\bingredients\s+(?:for|to\s+make)\b",
|
| 273 |
+
r"\bwhat\s+(?:do\s+i\s+need|ingredients)\s+(?:for|to\s+(?:make|cook))\b",
|
| 274 |
+
]
|
| 275 |
+
|
| 276 |
+
def _is_recipe_query(self, query: str) -> bool:
|
| 277 |
+
"""Pure-regex recipe intent check β no LLM call needed."""
|
| 278 |
+
import re
|
| 279 |
+
q = query.lower()
|
| 280 |
+
return any(re.search(p, q) for p in self._RECIPE_PATTERNS)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def tool_search_inventory(self, query: str) -> List[Dict[str, Any]]:
|
| 284 |
+
"""Search the store inventory for products matching the query."""
|
| 285 |
+
query_lower = query.lower().strip()
|
| 286 |
+
ing_tokens = set(t for t in query_lower.split() if len(t) >= 3 or t in ["ghee", "oil"])
|
| 287 |
+
|
| 288 |
+
matches = []
|
| 289 |
+
for item in self.inventory:
|
| 290 |
+
item_name = item.get("name", "").lower()
|
| 291 |
+
item_slug = item.get("slug", "").lower()
|
| 292 |
+
|
| 293 |
+
# Simple scoring
|
| 294 |
+
item_tokens = set(item_name.split() + item_slug.split("-"))
|
| 295 |
+
score = len(ing_tokens.intersection(item_tokens))
|
| 296 |
+
if query_lower in item_name or item_name in query_lower:
|
| 297 |
+
score += 5
|
| 298 |
+
|
| 299 |
+
if score > 0:
|
| 300 |
+
matches.append((item, score))
|
| 301 |
+
|
| 302 |
+
# Sort by score descending
|
| 303 |
+
matches.sort(key=lambda x: x[1], reverse=True)
|
| 304 |
+
return [item for item, score in matches[:10]]
|
| 305 |
+
|
| 306 |
+
def tool_add_to_cart(self, user_id: str, sku: str, quantity: int = 1, mutations: List[Dict[str, Any]] = None) -> str:
|
| 307 |
+
# Find item details
|
| 308 |
+
item = next((x for x in self.inventory if x.get("sku") == sku), None)
|
| 309 |
+
if not item:
|
| 310 |
+
return f"Error: SKU '{sku}' not found in inventory."
|
| 311 |
+
|
| 312 |
+
success = execute_database_cart_addition(user_id, sku, quantity)
|
| 313 |
+
if success and mutations is not None:
|
| 314 |
+
mutations.append({
|
| 315 |
+
"action": "add",
|
| 316 |
+
"sku": sku,
|
| 317 |
+
"name": item.get("name"),
|
| 318 |
+
"price": float(item.get("price_rupees", 0.0)),
|
| 319 |
+
"quantity": quantity,
|
| 320 |
+
"thumbnail_url": item.get("thumbnail_url", "")
|
| 321 |
+
})
|
| 322 |
+
return f"Successfully added {quantity} x '{item.get('name')}' (SKU: {sku}) to the cart."
|
| 323 |
+
|
| 324 |
+
def tool_remove_from_cart(self, user_id: str, sku: str, quantity: int = 1, mutations: List[Dict[str, Any]] = None) -> str:
|
| 325 |
+
item = next((x for x in self.inventory if x.get("sku") == sku), None)
|
| 326 |
+
if not item:
|
| 327 |
+
return f"Error: SKU '{sku}' not found in inventory."
|
| 328 |
+
|
| 329 |
+
success = execute_database_cart_removal(user_id, sku, quantity)
|
| 330 |
+
if success and mutations is not None:
|
| 331 |
+
mutations.append({
|
| 332 |
+
"action": "remove",
|
| 333 |
+
"sku": sku,
|
| 334 |
+
"name": item.get("name"),
|
| 335 |
+
"price": float(item.get("price_rupees", 0.0)),
|
| 336 |
+
"quantity": quantity
|
| 337 |
+
})
|
| 338 |
+
return f"Successfully removed/decremented {quantity} x '{item.get('name')}' (SKU: {sku}) from the cart."
|
| 339 |
+
|
| 340 |
+
def tool_clear_cart(self, user_id: str, mutations: List[Dict[str, Any]] = None) -> str:
|
| 341 |
+
live_cart_memory.clear_cart(user_id)
|
| 342 |
+
if mutations is not None:
|
| 343 |
+
mutations.append({
|
| 344 |
+
"action": "clear"
|
| 345 |
+
})
|
| 346 |
+
return "Successfully cleared all items from the cart."
|
| 347 |
+
|
| 348 |
+
async def _generate_and_match_recipe_internal(self, user_id: str, current_cart_slugs: List[str], dish_query: str, servings: int) -> Dict[str, Any]:
|
| 349 |
+
"""
|
| 350 |
+
Generates the recipe and runs matching against catalog.
|
| 351 |
+
This contains the original process_recipe_workflow implementation.
|
| 352 |
+
"""
|
| 353 |
+
try:
|
| 354 |
+
toxic_keywords = {"cleaner", "harpic", "lizol", "toilet", "disinfectant", "floor", "soap"}
|
| 355 |
+
processed_keywords = {"cerelac", "boost", "horlicks", "bournvita", "baby", "cereal", "chocos"}
|
| 356 |
+
sauce_keywords = {"sauce", "ketchup", "paste", "jam", "spread"}
|
| 357 |
+
snack_keywords = {"chips", "lays", "kurkure", "namkeen", "biscuit", "cookie", "bingo"}
|
| 358 |
+
utility_keywords = {"bottle", "flask", "container", "jar", "box", "spoon", "knife", "pan"}
|
| 359 |
+
|
| 360 |
+
query_lower = dish_query.lower()
|
| 361 |
+
if any(tk in query_lower for tk in toxic_keywords):
|
| 362 |
+
print(f"π [SECURITY BLOCK] Malicious payload injection caught in query: '{dish_query}'")
|
| 363 |
+
return {
|
| 364 |
+
"dish": str(dish_query),
|
| 365 |
+
"servings": int(servings),
|
| 366 |
+
"recipe_instructions": ["Recipe blocked due to safety violations."],
|
| 367 |
+
"parsed_ingredients": [],
|
| 368 |
+
"missing_ingredients": [],
|
| 369 |
+
"cart_additions": []
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
# 1. Generate and match recipe in a single LLM call
|
| 373 |
+
recipe_result = await self.recipe_agent.generate_and_match_recipe(dish_query, servings, self.inventory)
|
| 374 |
+
|
| 375 |
+
instructions_list = recipe_result.get("instructions", [])
|
| 376 |
+
ingredients = recipe_result.get("ingredients", [])
|
| 377 |
+
|
| 378 |
+
missing_ingredients_map = {}
|
| 379 |
+
cart_additions_map = {}
|
| 380 |
+
|
| 381 |
+
# Build two lookup sets for robust deduplication:
|
| 382 |
+
# 1. Normalized slugs (strip apostrophes/special chars Flutter adds)
|
| 383 |
+
# 2. SKU set from current_cart (authoritative β immune to slug drift)
|
| 384 |
+
import re as _re
|
| 385 |
+
def _norm_slug(s: str) -> str:
|
| 386 |
+
return _re.sub(r"[^a-z0-9\-]", "", str(s).lower().strip())
|
| 387 |
+
|
| 388 |
+
normalized_cart_slugs = [_norm_slug(slug) for slug in (current_cart_slugs or [])]
|
| 389 |
+
# current_cart_slugs are name-derived β also build a SKU set from current_cart
|
| 390 |
+
# NOTE: _generate_and_match_recipe_internal only receives cart_slugs, not
|
| 391 |
+
# the full current_cart. For now use the live_cart_memory which was just
|
| 392 |
+
# synced from Flutter as the SKU source of truth.
|
| 393 |
+
from app.utils.cart_state import live_cart_memory as _cart_mem
|
| 394 |
+
synced_skus = set(_cart_mem.get_cart(user_id).keys())
|
| 395 |
+
|
| 396 |
+
# Parse and compose the final missing ingredients and cart additions lists
|
| 397 |
+
for ing in ingredients:
|
| 398 |
+
ing_name = clean_ingredient_name(ing.get("name", ""), ing.get("unit", ""))
|
| 399 |
+
ing_name_lower = ing_name.lower().strip()
|
| 400 |
+
ing_sku = ing.get("sku", "UNKNOWN")
|
| 401 |
+
|
| 402 |
+
# Deduplicate slug format
|
| 403 |
+
ing_slug = ing.get("slug", ing_name_lower.replace(" ", "-")).lower().strip()
|
| 404 |
+
if not ing_slug:
|
| 405 |
+
ing_slug = ing_name_lower.replace(" ", "-")
|
| 406 |
+
|
| 407 |
+
if _norm_slug(ing_slug) in normalized_cart_slugs:
|
| 408 |
+
continue
|
| 409 |
+
|
| 410 |
+
final_qty = format_ingredient_quantity(ing.get('quantity'), ing.get('unit'))
|
| 411 |
+
|
| 412 |
+
if ing_sku != "UNKNOWN":
|
| 413 |
+
# Look up actual item from catalog to resolve name, slug, price, and thumbnail_url
|
| 414 |
+
actual_item = next((item for item in self.inventory if item.get("sku") == ing_sku), None)
|
| 415 |
+
if actual_item:
|
| 416 |
+
item_sku = actual_item.get("sku")
|
| 417 |
+
item_slug = actual_item.get("slug", ing_slug).lower().strip()
|
| 418 |
+
|
| 419 |
+
if item_sku in synced_skus or _norm_slug(item_slug) in normalized_cart_slugs:
|
| 420 |
+
continue
|
| 421 |
+
|
| 422 |
+
if item_sku in missing_ingredients_map:
|
| 423 |
+
missing_ingredients_map[item_sku]["required_quantity"] += f" + {final_qty}"
|
| 424 |
+
missing_ingredients_map[item_sku]["quantity"] = missing_ingredients_map[item_sku].get("quantity", 0) + _parse_qty(final_qty)
|
| 425 |
+
else:
|
| 426 |
+
missing_ingredients_map[item_sku] = {
|
| 427 |
+
"sku": item_sku,
|
| 428 |
+
"slug": item_slug,
|
| 429 |
+
"name": actual_item.get("name"),
|
| 430 |
+
"price_rupees": float(actual_item.get("price_rupees", 0.0)),
|
| 431 |
+
"thumbnail_url": actual_item.get("thumbnail_url", ""),
|
| 432 |
+
"required_quantity": final_qty,
|
| 433 |
+
"quantity": _parse_qty(final_qty),
|
| 434 |
+
"agent_tool_status": "Committed to DB"
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
if item_sku in cart_additions_map:
|
| 438 |
+
cart_additions_map[item_sku]["quantity"] += _parse_qty(final_qty)
|
| 439 |
+
else:
|
| 440 |
+
cart_additions_map[item_sku] = {
|
| 441 |
+
"sku": item_sku,
|
| 442 |
+
"name": actual_item.get("name"),
|
| 443 |
+
"price": float(actual_item.get("price_rupees", 0.0)),
|
| 444 |
+
"quantity": _parse_qty(final_qty)
|
| 445 |
+
}
|
| 446 |
+
else:
|
| 447 |
+
# Clean up substitute list if any (ensure thumbnail URLs from inventory match if applicable)
|
| 448 |
+
cleaned_subs = []
|
| 449 |
+
for sub in ing.get("substitutes", []):
|
| 450 |
+
sub_sku = sub.get("sku")
|
| 451 |
+
if sub_sku:
|
| 452 |
+
actual_sub = next((item for item in self.inventory if item.get("sku") == sub_sku), None)
|
| 453 |
+
if actual_sub:
|
| 454 |
+
cleaned_subs.append({
|
| 455 |
+
"sku": sub_sku,
|
| 456 |
+
"name": actual_sub.get("name"),
|
| 457 |
+
"price_rupees": float(actual_sub.get("price_rupees", 0.0)),
|
| 458 |
+
"thumbnail_url": actual_sub.get("thumbnail_url", "")
|
| 459 |
+
})
|
| 460 |
+
else:
|
| 461 |
+
cleaned_subs.append(sub)
|
| 462 |
+
else:
|
| 463 |
+
cleaned_subs.append(sub)
|
| 464 |
+
|
| 465 |
+
missing_ingredients_map[ing_slug] = {
|
| 466 |
+
"sku": "UNKNOWN",
|
| 467 |
+
"slug": ing_slug,
|
| 468 |
+
"name": ing_name,
|
| 469 |
+
"price_rupees": 0.0,
|
| 470 |
+
"thumbnail_url": "",
|
| 471 |
+
"required_quantity": final_qty,
|
| 472 |
+
"quantity": _parse_qty(final_qty),
|
| 473 |
+
"substitutes": cleaned_subs
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
return {
|
| 477 |
+
"dish": str(dish_query),
|
| 478 |
+
"servings": int(servings),
|
| 479 |
+
"instructions": list(instructions_list),
|
| 480 |
+
"recipe_instructions": list(instructions_list),
|
| 481 |
+
"ingredients": [
|
| 482 |
+
{
|
| 483 |
+
"name": clean_ingredient_name(ing.get("name", ""), ing.get("unit", "")),
|
| 484 |
+
"quantity": format_ingredient_quantity(ing.get("quantity"), ing.get("unit"))
|
| 485 |
+
}
|
| 486 |
+
for ing in ingredients
|
| 487 |
+
],
|
| 488 |
+
"parsed_ingredients": [],
|
| 489 |
+
"missing_ingredients": list(missing_ingredients_map.values()),
|
| 490 |
+
"cart_additions": list(cart_additions_map.values())
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
except Exception as e:
|
| 494 |
+
import traceback
|
| 495 |
+
print("β [CRITICAL PIPELINE EXCEPTION]")
|
| 496 |
+
traceback.print_exc()
|
| 497 |
+
return {"error": str(e), "missing_ingredients": [], "cart_additions": []}
|
| 498 |
+
|
| 499 |
+
async def process_recipe_workflow(
|
| 500 |
+
self,
|
| 501 |
+
user_id: str = "anonymous_user",
|
| 502 |
+
current_cart_slugs: List[str] = None,
|
| 503 |
+
dish_query: str = "",
|
| 504 |
+
servings: int = 2,
|
| 505 |
+
chat_history: List[Dict[str, Any]] = None,
|
| 506 |
+
current_cart: List[Dict[str, Any]] = None,
|
| 507 |
+
image_base64: str = None
|
| 508 |
+
) -> Dict[str, Any]:
|
| 509 |
+
generator = self.process_recipe_workflow_stream(
|
| 510 |
+
user_id=user_id,
|
| 511 |
+
current_cart_slugs=current_cart_slugs,
|
| 512 |
+
dish_query=dish_query,
|
| 513 |
+
servings=servings,
|
| 514 |
+
chat_history=chat_history,
|
| 515 |
+
current_cart=current_cart,
|
| 516 |
+
image_base64=image_base64
|
| 517 |
+
)
|
| 518 |
+
full_text = ""
|
| 519 |
+
recipe_data = None
|
| 520 |
+
mutations = None
|
| 521 |
+
llm_unavailable = False
|
| 522 |
+
async for chunk_str in generator:
|
| 523 |
+
if not chunk_str.strip():
|
| 524 |
+
continue
|
| 525 |
+
try:
|
| 526 |
+
chunk = json.loads(chunk_str.strip())
|
| 527 |
+
if chunk.get("llm_unavailable"):
|
| 528 |
+
llm_unavailable = True
|
| 529 |
+
elif "text_chunk" in chunk:
|
| 530 |
+
full_text += chunk["text_chunk"]
|
| 531 |
+
else:
|
| 532 |
+
if "recipe" in chunk:
|
| 533 |
+
recipe_data = chunk["recipe"]
|
| 534 |
+
if "cart_mutations" in chunk:
|
| 535 |
+
mutations = chunk["cart_mutations"]
|
| 536 |
+
except Exception as parse_err:
|
| 537 |
+
print(f"Error parsing generator chunk: {parse_err}")
|
| 538 |
+
# Handle case where the LLM may have output a JSON wrapper (legacy behavior)
|
| 539 |
+
response_text = full_text.strip()
|
| 540 |
+
if response_text.startswith('{'):
|
| 541 |
+
try:
|
| 542 |
+
parsed = json.loads(response_text)
|
| 543 |
+
if isinstance(parsed, dict) and "response_text" in parsed:
|
| 544 |
+
response_text = parsed["response_text"]
|
| 545 |
+
except Exception:
|
| 546 |
+
pass # Not JSON, use as-is
|
| 547 |
+
|
| 548 |
+
return {
|
| 549 |
+
"response_text": response_text,
|
| 550 |
+
"recipe": recipe_data,
|
| 551 |
+
"cart_mutations": mutations,
|
| 552 |
+
"llm_unavailable": llm_unavailable
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
async def process_recipe_workflow_stream(
|
| 556 |
+
self,
|
| 557 |
+
user_id: str = "anonymous_user",
|
| 558 |
+
current_cart_slugs: List[str] = None,
|
| 559 |
+
dish_query: str = "",
|
| 560 |
+
servings: int = 2,
|
| 561 |
+
chat_history: List[Dict[str, Any]] = None,
|
| 562 |
+
current_cart: List[Dict[str, Any]] = None,
|
| 563 |
+
image_base64: str = None
|
| 564 |
+
):
|
| 565 |
+
mutations: List[Dict[str, Any]] = []
|
| 566 |
+
recipe_data = None
|
| 567 |
+
|
| 568 |
+
# Format current cart details
|
| 569 |
+
cart_details_str = "No items in cart."
|
| 570 |
+
if current_cart:
|
| 571 |
+
cart_details_str = "\n".join([
|
| 572 |
+
f"- {item.get('name', 'Unknown')} (Quantity: {item.get('quantity', 1)}, SKU: {item.get('sku', 'UNKNOWN')})"
|
| 573 |
+
for item in current_cart
|
| 574 |
+
])
|
| 575 |
+
elif current_cart_slugs:
|
| 576 |
+
cart_details_str = "\n".join([
|
| 577 |
+
f"- {slug} (Quantity: 1)"
|
| 578 |
+
for slug in current_cart_slugs
|
| 579 |
+
])
|
| 580 |
+
|
| 581 |
+
# ββ Pre-fetch relevant inventory items for this query ββββββββββββββ
|
| 582 |
+
prefetched_items: List[Dict[str, Any]] = []
|
| 583 |
+
if dish_query and not image_base64:
|
| 584 |
+
prefetched_items = self._prefetch_relevant_items(dish_query)
|
| 585 |
+
|
| 586 |
+
# ββ Fast-path: if clearly a recipe query, skip LLM tool-call round-trip ββ
|
| 587 |
+
if dish_query and not image_base64 and self._is_recipe_query(dish_query):
|
| 588 |
+
print(f"π³ [RECIPE FAST-PATH] Detected recipe intent in: {dish_query!r}")
|
| 589 |
+
recipe_data = await self._generate_and_match_recipe_internal(
|
| 590 |
+
user_id=user_id,
|
| 591 |
+
current_cart_slugs=current_cart_slugs,
|
| 592 |
+
dish_query=dish_query,
|
| 593 |
+
servings=servings
|
| 594 |
+
)
|
| 595 |
+
yield json.dumps({"text_chunk": f"Here's your recipe for {dish_query}! I've matched the ingredients to our store catalog. Check the recipe card below."}) + "\n"
|
| 596 |
+
yield json.dumps({"cart_mutations": mutations if mutations else None, "recipe": recipe_data}) + "\n"
|
| 597 |
+
return
|
| 598 |
+
|
| 599 |
+
# Format the catalog details for system prompt
|
| 600 |
+
# When prefetch found relevant items, use those (with price) instead of the full catalog
|
| 601 |
+
# to save tokens. Fall back to full catalog for open-ended or greeting queries.
|
| 602 |
+
catalog_str = "No items in catalog."
|
| 603 |
+
if self.inventory:
|
| 604 |
+
if prefetched_items:
|
| 605 |
+
catalog_str = (
|
| 606 |
+
"(Showing items most relevant to this query β full catalog available via search_inventory tool)\n"
|
| 607 |
+
+ "\n".join(
|
| 608 |
+
f"- Name: {it.get('name')} | SKU: {it.get('sku')} | Price: βΉ{it.get('price_rupees', '?')}"
|
| 609 |
+
for it in prefetched_items
|
| 610 |
+
)
|
| 611 |
+
)
|
| 612 |
+
else:
|
| 613 |
+
catalog_str = "\n".join([
|
| 614 |
+
f"- Name: {item.get('name')} | SKU: {item.get('sku')}"
|
| 615 |
+
for item in self.inventory
|
| 616 |
+
])
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
tools_definitions = [
|
| 620 |
+
{
|
| 621 |
+
"type": "function",
|
| 622 |
+
"function": {
|
| 623 |
+
"name": "search_inventory",
|
| 624 |
+
"description": "Search the store inventory for products matching the query term. Returns items with SKU, name, price, and slug.",
|
| 625 |
+
"parameters": {
|
| 626 |
+
"type": "object",
|
| 627 |
+
"properties": {
|
| 628 |
+
"query": {
|
| 629 |
+
"type": "string",
|
| 630 |
+
"description": "The search term to look for in the catalog (e.g. 'snickers', 'milk', 'eggs')."
|
| 631 |
+
}
|
| 632 |
+
},
|
| 633 |
+
"required": ["query"]
|
| 634 |
+
}
|
| 635 |
+
}
|
| 636 |
+
},
|
| 637 |
+
{
|
| 638 |
+
"type": "function",
|
| 639 |
+
"function": {
|
| 640 |
+
"name": "add_to_cart",
|
| 641 |
+
"description": "Add a specific product to the user's cart by its SKU.",
|
| 642 |
+
"parameters": {
|
| 643 |
+
"type": "object",
|
| 644 |
+
"properties": {
|
| 645 |
+
"sku": {
|
| 646 |
+
"type": "string",
|
| 647 |
+
"description": "The SKU of the product to add."
|
| 648 |
+
},
|
| 649 |
+
"quantity": {
|
| 650 |
+
"type": "integer",
|
| 651 |
+
"description": "The quantity to add.",
|
| 652 |
+
"default": 1
|
| 653 |
+
}
|
| 654 |
+
},
|
| 655 |
+
"required": ["sku"]
|
| 656 |
+
}
|
| 657 |
+
}
|
| 658 |
+
},
|
| 659 |
+
{
|
| 660 |
+
"type": "function",
|
| 661 |
+
"function": {
|
| 662 |
+
"name": "remove_from_cart",
|
| 663 |
+
"description": "Remove or decrement a product from the user's cart by its SKU.",
|
| 664 |
+
"parameters": {
|
| 665 |
+
"type": "object",
|
| 666 |
+
"properties": {
|
| 667 |
+
"sku": {
|
| 668 |
+
"type": "string",
|
| 669 |
+
"description": "The SKU of the product to remove/decrement."
|
| 670 |
+
},
|
| 671 |
+
"quantity": {
|
| 672 |
+
"type": "integer",
|
| 673 |
+
"description": "The quantity to remove/decrement.",
|
| 674 |
+
"default": 1
|
| 675 |
+
}
|
| 676 |
+
},
|
| 677 |
+
"required": ["sku"]
|
| 678 |
+
}
|
| 679 |
+
}
|
| 680 |
+
},
|
| 681 |
+
{
|
| 682 |
+
"type": "function",
|
| 683 |
+
"function": {
|
| 684 |
+
"name": "generate_and_match_recipe",
|
| 685 |
+
"description": "Generate a cooking recipe for a SPECIFIC dish name (e.g. 'pasta', 'veg biryani'). DO NOT call this tool for general shopping recommendations, breakfast item suggestions, snacks, greetings, or conversational questions.",
|
| 686 |
+
"parameters": {
|
| 687 |
+
"type": "object",
|
| 688 |
+
"properties": {
|
| 689 |
+
"dish_name": {
|
| 690 |
+
"type": "string",
|
| 691 |
+
"description": "The name of the specific dish to generate a recipe for (e.g., 'Tomato Soup'). Do NOT pass general queries or lists here."
|
| 692 |
+
},
|
| 693 |
+
"servings": {
|
| 694 |
+
"type": "integer",
|
| 695 |
+
"description": "The number of servings.",
|
| 696 |
+
"default": 2
|
| 697 |
+
}
|
| 698 |
+
},
|
| 699 |
+
"required": ["dish_name"]
|
| 700 |
+
}
|
| 701 |
+
}
|
| 702 |
+
},
|
| 703 |
+
{
|
| 704 |
+
"type": "function",
|
| 705 |
+
"function": {
|
| 706 |
+
"name": "clear_cart",
|
| 707 |
+
"description": "ONLY call this tool when the user EXPLICITLY requests to clear, empty, or wipe their entire cart (e.g. 'clear my cart', 'empty my cart', 'remove all items', 'start fresh'). NEVER call this tool when the user wants to ADD items, REMOVE a specific item, or asks about the cart. Calling this tool destroys the entire cart, so it must be used with extreme caution.",
|
| 708 |
+
"parameters": {
|
| 709 |
+
"type": "object",
|
| 710 |
+
"properties": {}
|
| 711 |
+
}
|
| 712 |
+
}
|
| 713 |
+
},
|
| 714 |
+
{
|
| 715 |
+
"type": "function",
|
| 716 |
+
"function": {
|
| 717 |
+
"name": "get_nutrition_info",
|
| 718 |
+
"description": "Fetch real nutritional details (calories, protein, carbs, fats per 100g) for a single raw food or catalog product name from the USDA FoodData Central database. Use this tool when the user asks for nutritional facts, calorie counts, or macro information of specific foods.",
|
| 719 |
+
"parameters": {
|
| 720 |
+
"type": "object",
|
| 721 |
+
"properties": {
|
| 722 |
+
"item_query": {
|
| 723 |
+
"type": "string",
|
| 724 |
+
"description": "The exact food or ingredient name to query (e.g. 'Standardised Milk', 'Egg', 'Oats', 'Apple')."
|
| 725 |
+
}
|
| 726 |
+
},
|
| 727 |
+
"required": ["item_query"]
|
| 728 |
+
}
|
| 729 |
+
}
|
| 730 |
+
}
|
| 731 |
+
]
|
| 732 |
+
|
| 733 |
+
messages = [
|
| 734 |
+
{
|
| 735 |
+
"role": "system",
|
| 736 |
+
"content": f"""You are the Qless Assistant, an intelligent, conversational AI retail and cooking assistant for the Qless self-checkout store.
|
| 737 |
+
You help users with shopping suggestions, chitchat, cooking recipe inquiries, and real-time cart modifications.
|
| 738 |
+
|
| 739 |
+
Active User ID: {user_id}
|
| 740 |
+
|
| 741 |
+
Current Cart Items:
|
| 742 |
+
{cart_details_str}
|
| 743 |
+
|
| 744 |
+
*IMPORTANT NOTE ON CART STATE*: The "Current Cart Items" list above is the absolute, authoritative truth of what is currently in the user's cart. Any past requests in the chat history (e.g., "add 4 items") have already been fully executed and are already included in the list above. Refer only to the list above to know what is in the cart.
|
| 745 |
+
- Even if a product name mentioned in the chat history (e.g., "Cadbury Fuse") is slightly different from the name in the "Current Cart Items" list (e.g., "Cadbury Fuse Chocolate Bar"), they refer to the same item and you must NOT sum their quantities. The quantity in the "Current Cart Items" list is the ONLY quantity in the cart.
|
| 746 |
+
- Any item mentioned in the chat history that is NOT in the "Current Cart Items" list above has been removed and is NO LONGER in the cart. Do NOT list it or assume it is still in the cart.
|
| 747 |
+
- Do NOT sum, add, or double-count quantities from the chat history with the list above.
|
| 748 |
+
|
| 749 |
+
β οΈ CRITICAL β STALE CART DATA IN HISTORY: The chat history you receive may contain old assistant responses that listed cart contents (e.g., "Current Cart Items: Lays x5"). These are SNAPSHOTS of a PAST state and are ALWAYS WRONG about the current cart. The cart can be modified at any time from outside this chat (e.g., via the store scanner or manual clearing). You MUST COMPLETELY IGNORE any cart quantities or item lists mentioned in previous assistant messages. The ONLY correct cart state is the "Current Cart Items" block at the top of this system prompt. Treat any cart listing in the chat history as if it were from a different session entirely.
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
### Guidelines:
|
| 753 |
+
1. **Conversational Responses**: Be extremely friendly, natural, and helpful. Always write your final response as plain, readable text β never as JSON or code blocks.
|
| 754 |
+
2. **Tool Usage**: Use the tool-calling interface to search inventory, add/remove items, or match recipes.
|
| 755 |
+
3. **No Raw Tool Tags**: Do NOT write tool calls as raw text, XML, or `<function>` tags in your response content. Only use the official API tool-calling mechanism.
|
| 756 |
+
4. **No Hallucinations**: Only use the exact SKUs found in the inventory catalog.
|
| 757 |
+
4b. **CRITICAL β DO NOT CLEAR CART BY MISTAKE**: The `clear_cart` tool PERMANENTLY destroys the entire cart. You MUST NEVER call `clear_cart` when the user says "add", "buy", "get", or anything that sounds like they want to put something in the cart. Only call `clear_cart` if the user uses an explicit phrase like: "clear my cart", "empty cart", "wipe the cart", "remove everything", "start over". If in doubt, do NOT call it.
|
| 758 |
+
5. **Displaying Cart and Cart Quantities**:
|
| 759 |
+
- When asked to show, display, or list the cart, list each item on a new line in a clear, user-friendly bulleted list showing its name and quantity (e.g., "- Product Name: 2"). Do not list the SKU to keep the response clean. Do not call any tools to list the cart; rely strictly on the "Current Cart Items" list provided above.
|
| 760 |
+
- **CRITICAL**: The "Current Cart Items" block represents the absolute, exact, and up-to-date state of the user's cart. Past chat history requests (e.g., "add 4 items") are already fully processed and reflected in "Current Cart Items". Do NOT sum, add, or accumulate quantities from the chat history with the "Current Cart Items". Do NOT assume the user has items that are not explicitly present in the "Current Cart Items" list.
|
| 761 |
+
5b. **Cart Action Confirmations**: After successfully adding, removing, or clearing items from the cart via a tool call, respond with a short, friendly confirmation message ONLY about what you just did (e.g., "Done! I've added 5 Lays to your cart", "All clear! Your cart is now empty"). **NEVER list the full cart state or say 'Current Cart Items:' after an action** β this creates stale data in the chat history that causes confusion on future requests. Only list cart contents if the user explicitly asks "what's in my cart?" or "show my cart".
|
| 762 |
+
6. **Displaying Search Results / Products**: When listing products from inventory searches or queries:
|
| 763 |
+
- Always display them as a clean bulleted list on new lines (rather than inline or in paragraphs) for better readability.
|
| 764 |
+
- Show only the human-friendly product names.
|
| 765 |
+
- Do NOT display product SKUs (e.g., "QLS-XXXX") in your final response text unless the user specifically asks for the SKU.
|
| 766 |
+
7. **Semantic Relevance Filtering**: The `search_inventory` tool performs a keyword-based search and may return items that merely contain the search term in their name (e.g., searching for "milk" returns "Cadbury Dairy Milk Chocolate" and "Nestle Milkybar White Chocolate", and searching for "onion" returns "Cream and Onion Chips"). When responding to the user, you must intelligently filter these results to only include products that are semantically relevant to the user's actual request. For example, if the user asks for "milk" or raw cooking ingredients, do not list chocolates, chips, or baby foods even if they appeared in the search results.
|
| 767 |
+
8. **General Shopping Suggestions and Chitchat**:
|
| 768 |
+
- If the user asks for general shopping recommendations, breakfast item suggestions, snacks, greetings, or conversational questions, do NOT call the `generate_and_match_recipe` tool.
|
| 769 |
+
- Instead, respond conversationally using products that are available in the 'Available Store Catalog' list below (e.g., for breakfast suggest "Nestle Ceregrow Multigrain Cereal", "Standardised Milk", "Organic Eggs", etc.).
|
| 770 |
+
- Only call the `generate_and_match_recipe` tool when the user is explicitly requesting a recipe or cooking instructions for a specific dish.
|
| 771 |
+
9. **Parallel Tool Calls for Multiple Items**: When the user asks to add, remove, or search for multiple items in a single message (e.g., "add Lays, Snickers, and KitKat"), you MUST issue ALL required tool calls **in a single response as parallel calls** β not one-by-one across multiple turns. This ensures all items are processed reliably.
|
| 772 |
+
10. **NO EMOJIS**: Do NOT use emojis anywhere in your final responses. Keep your language clean, professional, and strictly without any emojis (like π, π§Ή, π½οΈ, π₯£, etc.).
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
Available Store Catalog (SKUs and Names):
|
| 776 |
+
{catalog_str}
|
| 777 |
+
|
| 778 |
+
*PRO-TIP FOR CATALOG USAGE*: Since you are provided with the absolute list of all available items in the store in the 'Available Store Catalog' section above, you can and must directly select the matching SKU and call `add_to_cart` or `remove_from_cart` immediately without needing to search the inventory first. Use the `search_inventory` tool ONLY if the item name requested by the user is ambiguous or not clearly matching any of the items in the catalog above.
|
| 779 |
+
"""
|
| 780 |
+
}
|
| 781 |
+
]
|
| 782 |
+
|
| 783 |
+
if chat_history:
|
| 784 |
+
for item in chat_history:
|
| 785 |
+
role = "user" if item.get("is_user") else "assistant"
|
| 786 |
+
messages.append({
|
| 787 |
+
"role": role,
|
| 788 |
+
"content": item.get("text") or ""
|
| 789 |
+
})
|
| 790 |
+
|
| 791 |
+
# Determine model to use (switch to vision-capable model if image is attached)
|
| 792 |
+
model_to_use = self.model
|
| 793 |
+
if image_base64:
|
| 794 |
+
model_to_use = "meta-llama/llama-4-scout-17b-16e-instruct"
|
| 795 |
+
user_content = [
|
| 796 |
+
{
|
| 797 |
+
"type": "text",
|
| 798 |
+
"text": dish_query if dish_query else "What is this image? Tell me if I should add it to my cart or how I can cook with it."
|
| 799 |
+
},
|
| 800 |
+
{
|
| 801 |
+
"type": "image_url",
|
| 802 |
+
"image_url": {
|
| 803 |
+
"url": f"data:image/jpeg;base64,{image_base64}"
|
| 804 |
+
}
|
| 805 |
+
}
|
| 806 |
+
]
|
| 807 |
+
else:
|
| 808 |
+
user_content = dish_query
|
| 809 |
+
# ββ Pre-fetch relevant inventory items and inject into user message ββ
|
| 810 |
+
if dish_query and not image_base64:
|
| 811 |
+
prefetched = self._prefetch_relevant_items(dish_query)
|
| 812 |
+
if prefetched:
|
| 813 |
+
product_lines = "\n".join(
|
| 814 |
+
f"- {it['name']} (SKU: {it['sku']}, Price: βΉ{it.get('price_rupees', '?')})"
|
| 815 |
+
for it in prefetched
|
| 816 |
+
)
|
| 817 |
+
user_content = (
|
| 818 |
+
f"{dish_query}\n\n"
|
| 819 |
+
f"[Context β Matching Store Products]\n"
|
| 820 |
+
f"{product_lines}\n"
|
| 821 |
+
f"(Use this list to answer directly; only call search_inventory if the user's request is "
|
| 822 |
+
f"not covered by these results.)"
|
| 823 |
+
)
|
| 824 |
+
|
| 825 |
+
# Add the latest user message
|
| 826 |
+
messages.append({
|
| 827 |
+
"role": "user",
|
| 828 |
+
"content": user_content
|
| 829 |
+
})
|
| 830 |
+
|
| 831 |
+
executed_tool_calls = set()
|
| 832 |
+
for loop_iter in range(6): # increased from 3: multi-item requests can need up to 6 sequential tool steps
|
| 833 |
+
|
| 834 |
+
content = ""
|
| 835 |
+
tool_calls_dict = {}
|
| 836 |
+
try:
|
| 837 |
+
loop = asyncio.get_event_loop()
|
| 838 |
+
def get_stream(m):
|
| 839 |
+
return self.client.chat.completions.create(
|
| 840 |
+
model=m,
|
| 841 |
+
messages=messages,
|
| 842 |
+
tools=tools_definitions,
|
| 843 |
+
tool_choice="auto",
|
| 844 |
+
max_tokens=1024,
|
| 845 |
+
temperature=0.3,
|
| 846 |
+
stream=True
|
| 847 |
+
)
|
| 848 |
+
|
| 849 |
+
try:
|
| 850 |
+
completion_stream = await loop.run_in_executor(None, lambda: get_stream(model_to_use))
|
| 851 |
+
except Exception as stream_err:
|
| 852 |
+
if model_to_use == self.model and self.fallback_model:
|
| 853 |
+
print(f"β οΈ [AGENT LLM FALLBACK] Main model {model_to_use} failed: {stream_err}. Falling back to {self.fallback_model}...")
|
| 854 |
+
model_to_use = self.fallback_model
|
| 855 |
+
completion_stream = await loop.run_in_executor(None, lambda: get_stream(model_to_use))
|
| 856 |
+
else:
|
| 857 |
+
raise stream_err
|
| 858 |
+
|
| 859 |
+
role = "assistant"
|
| 860 |
+
for chunk in completion_stream:
|
| 861 |
+
delta = chunk.choices[0].delta
|
| 862 |
+
if delta.content:
|
| 863 |
+
content += delta.content
|
| 864 |
+
yield json.dumps({"text_chunk": delta.content}) + "\n"
|
| 865 |
+
|
| 866 |
+
if delta.tool_calls:
|
| 867 |
+
for tc in delta.tool_calls:
|
| 868 |
+
idx = tc.index
|
| 869 |
+
if idx not in tool_calls_dict:
|
| 870 |
+
tool_calls_dict[idx] = {
|
| 871 |
+
"id": tc.id or "",
|
| 872 |
+
"type": "function",
|
| 873 |
+
"function": {
|
| 874 |
+
"name": tc.function.name or "",
|
| 875 |
+
"arguments": tc.function.arguments or ""
|
| 876 |
+
}
|
| 877 |
+
}
|
| 878 |
+
else:
|
| 879 |
+
if tc.id:
|
| 880 |
+
tool_calls_dict[idx]["id"] += tc.id
|
| 881 |
+
if tc.function.name:
|
| 882 |
+
tool_calls_dict[idx]["function"]["name"] += tc.function.name
|
| 883 |
+
if tc.function.arguments:
|
| 884 |
+
tool_calls_dict[idx]["function"]["arguments"] += tc.function.arguments
|
| 885 |
+
except Exception as e:
|
| 886 |
+
print(f"β οΈ [AGENT LLM FAULT] {e}")
|
| 887 |
+
if not content:
|
| 888 |
+
if mutations:
|
| 889 |
+
# Cart was mutated but final reply failed β still report what was done.
|
| 890 |
+
details = []
|
| 891 |
+
for mut in mutations:
|
| 892 |
+
act = mut.get("action")
|
| 893 |
+
name = mut.get("name", "items")
|
| 894 |
+
qty = mut.get("quantity", 1)
|
| 895 |
+
if act == "add":
|
| 896 |
+
details.append(f"added {qty} {name}")
|
| 897 |
+
elif act == "remove":
|
| 898 |
+
details.append(f"removed {qty} {name}")
|
| 899 |
+
elif act == "clear":
|
| 900 |
+
details.append("cleared your cart")
|
| 901 |
+
summary_str = ", ".join(details)
|
| 902 |
+
yield json.dumps({"text_chunk": f"Done! I've successfully {summary_str}, but I'm having trouble generating the final reply. Please check your cart to verify."}) + "\n"
|
| 903 |
+
else:
|
| 904 |
+
# No content, no mutations β backend is unusable for this request.
|
| 905 |
+
# Signal 503 so the Flutter client retries the next backend URL.
|
| 906 |
+
print(f"β οΈ [AGENT LLM FAULT] No content produced β signalling llm_unavailable to trigger client failover")
|
| 907 |
+
yield json.dumps({"llm_unavailable": True}) + "\n"
|
| 908 |
+
break
|
| 909 |
+
|
| 910 |
+
# Reconstruct SimpleNamespace for internal checks
|
| 911 |
+
tool_calls = []
|
| 912 |
+
if tool_calls_dict:
|
| 913 |
+
from types import SimpleNamespace
|
| 914 |
+
for idx in sorted(tool_calls_dict.keys()):
|
| 915 |
+
tc_data = tool_calls_dict[idx]
|
| 916 |
+
tool_calls.append(SimpleNamespace(
|
| 917 |
+
id=tc_data["id"],
|
| 918 |
+
type="function",
|
| 919 |
+
function=SimpleNamespace(
|
| 920 |
+
name=tc_data["function"]["name"],
|
| 921 |
+
arguments=tc_data["function"]["arguments"]
|
| 922 |
+
)
|
| 923 |
+
))
|
| 924 |
+
|
| 925 |
+
from types import SimpleNamespace
|
| 926 |
+
msg = SimpleNamespace(
|
| 927 |
+
role=role,
|
| 928 |
+
content=content if content else None,
|
| 929 |
+
tool_calls=tool_calls if tool_calls else None
|
| 930 |
+
)
|
| 931 |
+
|
| 932 |
+
# Append plain dict to message history list for API compatibility
|
| 933 |
+
api_msg = {
|
| 934 |
+
"role": role,
|
| 935 |
+
"content": content if content else None
|
| 936 |
+
}
|
| 937 |
+
if tool_calls_dict:
|
| 938 |
+
api_msg["tool_calls"] = [
|
| 939 |
+
{
|
| 940 |
+
"id": tc_data["id"],
|
| 941 |
+
"type": "function",
|
| 942 |
+
"function": {
|
| 943 |
+
"name": tc_data["function"]["name"],
|
| 944 |
+
"arguments": tc_data["function"]["arguments"]
|
| 945 |
+
}
|
| 946 |
+
}
|
| 947 |
+
for tc_data in tool_calls_dict.values()
|
| 948 |
+
]
|
| 949 |
+
messages.append(api_msg)
|
| 950 |
+
|
| 951 |
+
if not msg.tool_calls:
|
| 952 |
+
break
|
| 953 |
+
|
| 954 |
+
# Check loop prevention first
|
| 955 |
+
should_break = False
|
| 956 |
+
for tool_call in msg.tool_calls:
|
| 957 |
+
call_signature = (tool_call.function.name, tool_call.function.arguments)
|
| 958 |
+
if call_signature in executed_tool_calls:
|
| 959 |
+
print(f"π [LOOP PREVENTED] Agent attempted to call tool '{tool_call.function.name}' with identical arguments {tool_call.function.arguments} again. Breaking loop.")
|
| 960 |
+
should_break = True
|
| 961 |
+
break
|
| 962 |
+
executed_tool_calls.add(call_signature)
|
| 963 |
+
|
| 964 |
+
if should_break:
|
| 965 |
+
break
|
| 966 |
+
|
| 967 |
+
# Execute tool calls in parallel
|
| 968 |
+
async def execute_single_tool(tool_call) -> Dict[str, Any]:
|
| 969 |
+
nonlocal recipe_data
|
| 970 |
+
tool_name = tool_call.function.name
|
| 971 |
+
tool_args_str = tool_call.function.arguments
|
| 972 |
+
arguments = json.loads(tool_args_str)
|
| 973 |
+
print(f"π οΈ [TOOL CALL] {tool_name} with args {arguments}")
|
| 974 |
+
|
| 975 |
+
result_str = ""
|
| 976 |
+
if tool_name == "search_inventory":
|
| 977 |
+
res = await loop.run_in_executor(None, lambda: self.tool_search_inventory(arguments.get("query", "")))
|
| 978 |
+
result_str = json.dumps(res)
|
| 979 |
+
elif tool_name == "add_to_cart":
|
| 980 |
+
res = await loop.run_in_executor(None, lambda: self.tool_add_to_cart(user_id, arguments.get("sku"), arguments.get("quantity", 1), mutations))
|
| 981 |
+
result_str = res
|
| 982 |
+
elif tool_name == "remove_from_cart":
|
| 983 |
+
res = await loop.run_in_executor(None, lambda: self.tool_remove_from_cart(user_id, arguments.get("sku"), arguments.get("quantity", 1), mutations))
|
| 984 |
+
result_str = res
|
| 985 |
+
elif tool_name == "clear_cart":
|
| 986 |
+
res = await loop.run_in_executor(None, lambda: self.tool_clear_cart(user_id, mutations))
|
| 987 |
+
result_str = res
|
| 988 |
+
# Signal the outer loop to short-circuit with a canned response
|
| 989 |
+
return {
|
| 990 |
+
"role": "tool",
|
| 991 |
+
"tool_call_id": tool_call.id,
|
| 992 |
+
"name": tool_name,
|
| 993 |
+
"content": result_str,
|
| 994 |
+
"__fast_path_response": "Done! Your cart has been cleared. Let me know if you'd like to add anything!"
|
| 995 |
+
}
|
| 996 |
+
elif tool_name == "generate_and_match_recipe":
|
| 997 |
+
recipe_data = await self._generate_and_match_recipe_internal(
|
| 998 |
+
user_id=user_id,
|
| 999 |
+
current_cart_slugs=current_cart_slugs,
|
| 1000 |
+
dish_query=arguments.get("dish_name"),
|
| 1001 |
+
servings=arguments.get("servings", servings)
|
| 1002 |
+
)
|
| 1003 |
+
result_str = "Successfully generated recipe."
|
| 1004 |
+
elif tool_name == "get_nutrition_info":
|
| 1005 |
+
item_query = arguments.get("item_query", "")
|
| 1006 |
+
res = await self.nutrition_service.get_nutrition(item_query)
|
| 1007 |
+
if res:
|
| 1008 |
+
result_str = json.dumps({
|
| 1009 |
+
"matched_item": item_query,
|
| 1010 |
+
"nutrients_per_100g": {
|
| 1011 |
+
"calories": f"{res.get('calories')} kcal",
|
| 1012 |
+
"protein": f"{res.get('protein')}g",
|
| 1013 |
+
"carbohydrates": f"{res.get('carbs')}g",
|
| 1014 |
+
"fats": f"{res.get('fat')}g"
|
| 1015 |
+
}
|
| 1016 |
+
})
|
| 1017 |
+
else:
|
| 1018 |
+
result_str = f"Sorry, could not find nutritional values for '{item_query}'."
|
| 1019 |
+
else:
|
| 1020 |
+
result_str = f"Error: Tool '{tool_name}' not found."
|
| 1021 |
+
|
| 1022 |
+
return {
|
| 1023 |
+
"role": "tool",
|
| 1024 |
+
"tool_call_id": tool_call.id,
|
| 1025 |
+
"name": tool_name,
|
| 1026 |
+
"content": result_str
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
tasks = [execute_single_tool(tc) for tc in msg.tool_calls]
|
| 1030 |
+
responses = await asyncio.gather(*tasks)
|
| 1031 |
+
|
| 1032 |
+
# Check if any tool requested a fast-path early exit
|
| 1033 |
+
fast_path_msg = None
|
| 1034 |
+
for resp in responses:
|
| 1035 |
+
if resp and resp.get("__fast_path_response"):
|
| 1036 |
+
fast_path_msg = resp.pop("__fast_path_response")
|
| 1037 |
+
break
|
| 1038 |
+
|
| 1039 |
+
messages.extend(responses)
|
| 1040 |
+
|
| 1041 |
+
if fast_path_msg:
|
| 1042 |
+
yield json.dumps({"text_chunk": fast_path_msg}) + "\n"
|
| 1043 |
+
break
|
| 1044 |
+
|
| 1045 |
+
if should_break:
|
| 1046 |
+
break
|
| 1047 |
+
|
| 1048 |
+
# Yield final metadata chunk
|
| 1049 |
+
yield json.dumps({
|
| 1050 |
+
"cart_mutations": mutations if mutations else None,
|
| 1051 |
+
"recipe": recipe_data if recipe_data else None
|
| 1052 |
+
}) + "\n"
|
app/agents/tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Agent tools package initialization
|
app/agents/tools/quantity_parser_tool.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import Dict, Any, Optional
|
| 3 |
+
|
| 4 |
+
# Words that describe preparation, not the ingredient itself β stripped from name
|
| 5 |
+
_PREP_WORDS = re.compile(
|
| 6 |
+
r"^(chopped|minced|diced|grated|sliced|crushed|ground|peeled|toasted|roasted|"
|
| 7 |
+
r"fresh|freshly|frozen|canned|organic|whole|small|large|medium|"
|
| 8 |
+
r"optional|divided|cloves?|"
|
| 9 |
+
r"cups?|tablespoons?|tbsp|teaspoons?|tsp|grams?|g|kg|ml|liters?|l|"
|
| 10 |
+
r"pinch|sprinkle|dash|drop|inch(?:es)?|pieces?|sticks?|slices?)\s+",
|
| 11 |
+
re.IGNORECASE
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
# Unit words that can follow conversational quantities ("to taste", "for garnish")
|
| 15 |
+
_CONVERSATIONAL_UNITS = {"pinch", "dash", "drop", "sprinkle"}
|
| 16 |
+
|
| 17 |
+
class QuantityParserTool:
|
| 18 |
+
"""Tool to parse ingredients and extract quantity information"""
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
def get_tool_definition() -> Dict[str, Any]:
|
| 22 |
+
"""Returns the tool definition for Groq function calling"""
|
| 23 |
+
return {
|
| 24 |
+
"type": "function",
|
| 25 |
+
"function": {
|
| 26 |
+
"name": "parse_ingredient_quantity",
|
| 27 |
+
"description": "Parse an ingredient string to extract quantity, unit, and ingredient name. Handles various formats like '1 cup flour', '2 tbsp butter', '3 eggs', etc.",
|
| 28 |
+
"parameters": {
|
| 29 |
+
"type": "object",
|
| 30 |
+
"properties": {
|
| 31 |
+
"ingredient_string": {
|
| 32 |
+
"type": "string",
|
| 33 |
+
"description": "The ingredient string to parse (e.g., '2 cups flour', '1 tablespoon olive oil')"
|
| 34 |
+
}
|
| 35 |
+
},
|
| 36 |
+
"required": ["ingredient_string"]
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
@staticmethod
|
| 42 |
+
def _strip_prep_words(name: str) -> str:
|
| 43 |
+
"""Remove leading preparation descriptors (chopped, fresh, etc.) from name"""
|
| 44 |
+
while True:
|
| 45 |
+
cleaned = _PREP_WORDS.sub("", name).strip()
|
| 46 |
+
if cleaned == name:
|
| 47 |
+
break
|
| 48 |
+
name = cleaned
|
| 49 |
+
return name.strip("-, ")
|
| 50 |
+
|
| 51 |
+
@staticmethod
|
| 52 |
+
def _handle_conversational_phrases(ingredient: str, raw_input: str) -> Optional[Dict[str, Any]]:
|
| 53 |
+
"""Handle non-numeric conversational phrases like 'to taste', 'for garnish', 'pinch of'"""
|
| 54 |
+
ingredient_lower = ingredient.lower()
|
| 55 |
+
|
| 56 |
+
# "to taste" β can appear at start or end ("to taste Pepper" / "Salt to taste")
|
| 57 |
+
if "to taste" in ingredient_lower:
|
| 58 |
+
name = re.sub(r"\bto taste\b", "", ingredient, flags=re.IGNORECASE).strip("-, ")
|
| 59 |
+
unit, name = QuantityParserTool._extract_conversational_unit(name)
|
| 60 |
+
return {
|
| 61 |
+
"quantity": "to taste",
|
| 62 |
+
"unit": unit,
|
| 63 |
+
"name": QuantityParserTool._strip_prep_words(name),
|
| 64 |
+
"raw_input": raw_input
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# "as needed" β "as needed Spices", "as needed Salt"
|
| 68 |
+
if "as needed" in ingredient_lower[:20]:
|
| 69 |
+
name = re.sub(r"\bas needed\b", "", ingredient, flags=re.IGNORECASE).strip("-, ")
|
| 70 |
+
unit, name = QuantityParserTool._extract_conversational_unit(name)
|
| 71 |
+
return {
|
| 72 |
+
"quantity": "as needed",
|
| 73 |
+
"unit": unit,
|
| 74 |
+
"name": QuantityParserTool._strip_prep_words(name),
|
| 75 |
+
"raw_input": raw_input
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
# "for garnish" β "Fresh Cilantro for garnish"
|
| 79 |
+
if "for garnish" in ingredient_lower:
|
| 80 |
+
name = re.sub(r"\bfor garnish\b", "", ingredient, flags=re.IGNORECASE).strip("-, ")
|
| 81 |
+
unit, name = QuantityParserTool._extract_conversational_unit(name)
|
| 82 |
+
return {
|
| 83 |
+
"quantity": "for garnish",
|
| 84 |
+
"unit": unit,
|
| 85 |
+
"name": QuantityParserTool._strip_prep_words(name),
|
| 86 |
+
"raw_input": raw_input
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
# "a pinch of ..." / "pinch of ..." / "1 pinch of ..." / "2 pinches of ..."
|
| 90 |
+
pinch_match = re.match(r"^(\d+\.?\d*)?\s*(a\s+)?pinch(?:es)?\s+of\s+(.+)", ingredient, re.IGNORECASE)
|
| 91 |
+
if pinch_match:
|
| 92 |
+
quantity = pinch_match.group(1) or "1"
|
| 93 |
+
name = pinch_match.group(3).strip()
|
| 94 |
+
return {
|
| 95 |
+
"quantity": quantity.strip(),
|
| 96 |
+
"unit": "pinch",
|
| 97 |
+
"name": QuantityParserTool._strip_prep_words(name),
|
| 98 |
+
"raw_input": raw_input
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def _extract_conversational_unit(text: str) -> tuple:
|
| 105 |
+
"""After stripping a conversational phrase, check if the remainder starts
|
| 106 |
+
with a known unit word (e.g. 'pinch', 'dash') and extract it."""
|
| 107 |
+
text = text.strip()
|
| 108 |
+
for word in sorted(_CONVERSATIONAL_UNITS, key=len, reverse=True):
|
| 109 |
+
if text.lower().startswith(word) and (len(text) == len(word) or not text[len(word)].isalpha()):
|
| 110 |
+
return word, text[len(word):].strip("-, ")
|
| 111 |
+
return "", text
|
| 112 |
+
|
| 113 |
+
@staticmethod
|
| 114 |
+
def _post_process(result: Dict[str, Any]) -> Dict[str, Any]:
|
| 115 |
+
"""Clean up residual noise: trailing dashes on quantities and duplicate word leaks in names."""
|
| 116 |
+
# Clean trailing dashes from quantity strings (e.g. "1-" β "1")
|
| 117 |
+
qty = result.get("quantity", "")
|
| 118 |
+
if qty.endswith("-"):
|
| 119 |
+
qty = qty.rstrip("-").strip()
|
| 120 |
+
result["quantity"] = qty
|
| 121 |
+
|
| 122 |
+
# De-duplicate name words where one is a case-insensitive substring of another
|
| 123 |
+
# e.g. "onion Onions" β "Onions", "Salt salt" β "Salt"
|
| 124 |
+
name = result.get("name", "")
|
| 125 |
+
words = name.split()
|
| 126 |
+
if len(words) >= 2:
|
| 127 |
+
cleaned = []
|
| 128 |
+
for w in words:
|
| 129 |
+
wl = w.lower()
|
| 130 |
+
dup = False
|
| 131 |
+
for j, (existing, existing_lower) in enumerate(cleaned):
|
| 132 |
+
if wl == existing_lower or wl in existing_lower or existing_lower in wl:
|
| 133 |
+
if len(w) >= len(existing):
|
| 134 |
+
cleaned[j] = (w, wl)
|
| 135 |
+
dup = True
|
| 136 |
+
break
|
| 137 |
+
if not dup:
|
| 138 |
+
cleaned.append((w, wl))
|
| 139 |
+
name = " ".join(c[0] for c in cleaned)
|
| 140 |
+
result["name"] = name
|
| 141 |
+
|
| 142 |
+
return result
|
| 143 |
+
|
| 144 |
+
@staticmethod
|
| 145 |
+
def execute(ingredient_string: str) -> Dict[str, Any]:
|
| 146 |
+
"""Parse an ingredient string and extract components"""
|
| 147 |
+
ingredient = ingredient_string.strip()
|
| 148 |
+
|
| 149 |
+
# Remove leading symbols (β’, β’, -, +)
|
| 150 |
+
ingredient = re.sub(r"^[\sβ’β’\-+]*", "", ingredient).strip()
|
| 151 |
+
|
| 152 |
+
# Remove numbered list markers (e.g., "3. ")
|
| 153 |
+
ingredient = re.sub(r"^\d+\.\s*", "", ingredient).strip()
|
| 154 |
+
|
| 155 |
+
# Handle conversational phrases before regex parsing
|
| 156 |
+
conv_result = QuantityParserTool._handle_conversational_phrases(ingredient, ingredient_string)
|
| 157 |
+
if conv_result:
|
| 158 |
+
return QuantityParserTool._post_process(conv_result)
|
| 159 |
+
|
| 160 |
+
# Match ANY numeric quantity, unicode fraction, or fraction expression at the start
|
| 161 |
+
num_match = re.match(r"^([\dΒ½ΒΌΒΎβ
β
β
\/\.\-\s]+)", ingredient)
|
| 162 |
+
|
| 163 |
+
if num_match and num_match.group(1).strip():
|
| 164 |
+
quantity_num = num_match.group(1).strip()
|
| 165 |
+
remainder = ingredient[num_match.end():].strip()
|
| 166 |
+
|
| 167 |
+
# Check if the remainder starts with a recognized unit
|
| 168 |
+
unit_pattern = r"^(cups?|tablespoons?|tbsp|teaspoons?|tsp|grams?|g|kg|ml|liters?|l)\b"
|
| 169 |
+
unit_match = re.match(unit_pattern, remainder, re.IGNORECASE)
|
| 170 |
+
|
| 171 |
+
if unit_match:
|
| 172 |
+
unit = unit_match.group(1)
|
| 173 |
+
name = remainder[unit_match.end():].strip()
|
| 174 |
+
quantity = f"{quantity_num} {unit}"
|
| 175 |
+
else:
|
| 176 |
+
# No standard unit matched
|
| 177 |
+
quantity = quantity_num
|
| 178 |
+
name = remainder
|
| 179 |
+
|
| 180 |
+
# Clean up content in parentheses from the name
|
| 181 |
+
name = re.sub(r"\([^)]*\)", "", name).strip()
|
| 182 |
+
|
| 183 |
+
return QuantityParserTool._post_process({
|
| 184 |
+
"quantity": quantity,
|
| 185 |
+
"unit": unit_match.group(1) if unit_match else "",
|
| 186 |
+
"name": QuantityParserTool._strip_prep_words(name),
|
| 187 |
+
"raw_input": ingredient_string
|
| 188 |
+
})
|
| 189 |
+
|
| 190 |
+
# Fallback: Check if it starts with a single digit or fraction symbol
|
| 191 |
+
fallback_match = re.match(r"^([0-9Β½ΒΌΒΎβ
β
β
])\s*(.*)$", ingredient)
|
| 192 |
+
if fallback_match:
|
| 193 |
+
return QuantityParserTool._post_process({
|
| 194 |
+
"quantity": fallback_match.group(1),
|
| 195 |
+
"unit": "",
|
| 196 |
+
"name": QuantityParserTool._strip_prep_words(fallback_match.group(2).strip()),
|
| 197 |
+
"raw_input": ingredient_string
|
| 198 |
+
})
|
| 199 |
+
|
| 200 |
+
# No quantity found, assume ingredient name only
|
| 201 |
+
return QuantityParserTool._post_process({
|
| 202 |
+
"quantity": "",
|
| 203 |
+
"unit": "",
|
| 204 |
+
"name": QuantityParserTool._strip_prep_words(ingredient),
|
| 205 |
+
"raw_input": ingredient_string
|
| 206 |
+
})
|
app/config.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
# Model settings
|
| 4 |
+
MODEL_ID = "Xenova/clip-vit-base-patch32"
|
| 5 |
+
PROCESSOR_ID = "openai/clip-vit-base-patch32"
|
| 6 |
+
DEVICE = "cpu"
|
| 7 |
+
|
| 8 |
+
# ONNX options
|
| 9 |
+
INTRA_OP_NUM_THREADS = 4
|
| 10 |
+
INTER_OP_NUM_THREADS = 4
|
| 11 |
+
|
| 12 |
+
# Storage paths
|
| 13 |
+
IMAGES_DIR = "captured_images"
|
| 14 |
+
|
| 15 |
+
# Vector Search
|
| 16 |
+
SIMILARITY_THRESHOLD = 0.65
|
| 17 |
+
|
| 18 |
+
# Object Detection (YOLOv8n ONNX)
|
| 19 |
+
ENABLE_OBJECT_DETECTION = os.getenv("ENABLE_OBJECT_DETECTION", "false").lower() == "true"
|
| 20 |
+
YOLO_MODEL_ID = os.getenv("YOLO_MODEL_ID", "ultralytics/yolov8n")
|
| 21 |
+
YOLO_MODEL_FILENAME = os.getenv("YOLO_MODEL_FILENAME", "yolov8n.onnx")
|
| 22 |
+
DETECTION_CONFIDENCE_THRESHOLD = float(os.getenv("DETECTION_CONFIDENCE_THRESHOLD", "0.25"))
|
| 23 |
+
DETECTION_IOU_THRESHOLD = float(os.getenv("DETECTION_IOU_THRESHOLD", "0.45"))
|
| 24 |
+
MAX_DETECTIONS = int(os.getenv("MAX_DETECTIONS", "3"))
|
| 25 |
+
CROP_PADDING_RATIO = float(os.getenv("CROP_PADDING_RATIO", "0.10"))
|
app/main.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
# Explicitly load .env file from the hf_server directory
|
| 5 |
+
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 6 |
+
dotenv_path = os.path.join(base_dir, ".env")
|
| 7 |
+
load_dotenv(dotenv_path=dotenv_path, override=True)
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from .routes import health, detection, chat, cart_analysis
|
| 12 |
+
from .services.http_client import init_http_client, close_http_client
|
| 13 |
+
|
| 14 |
+
app = FastAPI()
|
| 15 |
+
|
| 16 |
+
# Enable CORS so Flutter Web or local clients can call it directly
|
| 17 |
+
app.add_middleware(
|
| 18 |
+
CORSMiddleware,
|
| 19 |
+
allow_origins=[
|
| 20 |
+
"http://localhost:50220",
|
| 21 |
+
"http://127.0.0.1:50220",
|
| 22 |
+
"http://localhost:8000",
|
| 23 |
+
"http://127.0.0.1:8000",
|
| 24 |
+
],
|
| 25 |
+
allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?",
|
| 26 |
+
allow_credentials=True,
|
| 27 |
+
allow_methods=["*"],
|
| 28 |
+
allow_headers=["*"],
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Include routers
|
| 32 |
+
app.include_router(health.router)
|
| 33 |
+
app.include_router(detection.router)
|
| 34 |
+
app.include_router(chat.router)
|
| 35 |
+
app.include_router(cart_analysis.router)
|
| 36 |
+
|
| 37 |
+
@app.on_event("startup")
|
| 38 |
+
async def startup_event():
|
| 39 |
+
await init_http_client()
|
| 40 |
+
|
| 41 |
+
@app.on_event("shutdown")
|
| 42 |
+
async def shutdown_event():
|
| 43 |
+
await close_http_client()
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .chat import ChatRequest
|
| 2 |
+
|
| 3 |
+
__all__ = ["ChatRequest"]
|
app/models/chat.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
class RecipeStructure(BaseModel):
|
| 5 |
+
dish: str
|
| 6 |
+
servings: int
|
| 7 |
+
ingredients: List[str]
|
| 8 |
+
instructions: List[str]
|
| 9 |
+
|
| 10 |
+
class ChatMessagePayload(BaseModel):
|
| 11 |
+
is_user: bool
|
| 12 |
+
text: str
|
| 13 |
+
|
| 14 |
+
class CartItemPayload(BaseModel):
|
| 15 |
+
sku: str
|
| 16 |
+
name: str
|
| 17 |
+
quantity: int
|
| 18 |
+
|
| 19 |
+
class ChatRequest(BaseModel):
|
| 20 |
+
user_id: str
|
| 21 |
+
current_cart_slugs: List[str]
|
| 22 |
+
dish_query: str
|
| 23 |
+
servings: int
|
| 24 |
+
chat_history: Optional[List[ChatMessagePayload]] = None
|
| 25 |
+
current_cart: Optional[List[CartItemPayload]] = None
|
| 26 |
+
image_base64: Optional[str] = None
|
app/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Routes package initialization
|
app/routes/cart_analysis.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional, Any, Dict
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from fastapi import APIRouter, HTTPException
|
| 4 |
+
import traceback
|
| 5 |
+
|
| 6 |
+
from app.agents.missing_regulars_agent import MissingRegularsAgent
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/cart-analysis", tags=["Cart Analysis"])
|
| 9 |
+
agent = MissingRegularsAgent()
|
| 10 |
+
|
| 11 |
+
class CartItem(BaseModel):
|
| 12 |
+
id: str = None
|
| 13 |
+
sku: str = None
|
| 14 |
+
name: str = None
|
| 15 |
+
price: float = 0.0
|
| 16 |
+
quantity: int = 1
|
| 17 |
+
|
| 18 |
+
def dict(self, **kwargs):
|
| 19 |
+
return super().model_dump(**kwargs)
|
| 20 |
+
|
| 21 |
+
class CartAnalysisRequest(BaseModel):
|
| 22 |
+
user_id: str
|
| 23 |
+
current_cart: List[CartItem] = []
|
| 24 |
+
|
| 25 |
+
@router.post("/missing-regulars")
|
| 26 |
+
async def get_missing_regulars(payload: CartAnalysisRequest):
|
| 27 |
+
"""
|
| 28 |
+
Analyzes the user's past 90 days of order history to identify items
|
| 29 |
+
they buy regularly but haven't added to their current cart.
|
| 30 |
+
Returns a friendly LLM-generated message and a list of structured item suggestions.
|
| 31 |
+
"""
|
| 32 |
+
try:
|
| 33 |
+
user = str(payload.user_id).lower().strip()
|
| 34 |
+
# Convert pydantic models to dicts for the agent
|
| 35 |
+
current_cart = [item.dict() for item in payload.current_cart]
|
| 36 |
+
|
| 37 |
+
result = await agent.analyze_cart(user_id=user, current_cart=current_cart)
|
| 38 |
+
return result
|
| 39 |
+
|
| 40 |
+
except Exception as e:
|
| 41 |
+
print("\n=== CRITICAL API ROUTE ERROR TRACEBACK ===")
|
| 42 |
+
traceback.print_exc()
|
| 43 |
+
print("==========================================\n")
|
| 44 |
+
raise HTTPException(status_code=500, detail=str(e))
|
app/routes/chat.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import traceback
|
| 2 |
+
import json
|
| 3 |
+
from fastapi import APIRouter, HTTPException
|
| 4 |
+
from fastapi.responses import StreamingResponse
|
| 5 |
+
from app.models.chat import ChatRequest
|
| 6 |
+
from app.agents.shopping_assistant_agent import ShoppingAssistantAgent
|
| 7 |
+
from app.utils.cart_state import live_cart_memory
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/chat", tags=["Chat Management"])
|
| 10 |
+
agent = ShoppingAssistantAgent()
|
| 11 |
+
|
| 12 |
+
@router.post("/message")
|
| 13 |
+
async def send_chat_message(payload: ChatRequest):
|
| 14 |
+
"""
|
| 15 |
+
Non-streaming endpoint for backward compatibility (e.g. cart sync, tests).
|
| 16 |
+
Accumulates the generator chunks and returns a flat JSON dictionary.
|
| 17 |
+
"""
|
| 18 |
+
try:
|
| 19 |
+
user = str(payload.user_id).lower().strip()
|
| 20 |
+
slugs = [str(s) for s in payload.current_cart_slugs]
|
| 21 |
+
query = str(payload.dish_query)
|
| 22 |
+
srv = int(payload.servings)
|
| 23 |
+
|
| 24 |
+
history = []
|
| 25 |
+
if payload.chat_history:
|
| 26 |
+
history = [{"is_user": h.is_user, "text": h.text} for h in payload.chat_history]
|
| 27 |
+
|
| 28 |
+
current_cart = []
|
| 29 |
+
if payload.current_cart:
|
| 30 |
+
current_cart = [{"sku": c.sku, "name": c.name, "quantity": c.quantity} for c in payload.current_cart]
|
| 31 |
+
|
| 32 |
+
# ββ Source-of-truth sync ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
# Flutter's CartService is the authoritative cart state. Any external
|
| 34 |
+
# modifications (dashboard scanner, checkout, manual cart clear) are
|
| 35 |
+
# reflected here by resetting the server-side memory to match before
|
| 36 |
+
# running any tool calls.
|
| 37 |
+
live_cart_memory.sync_from_client(user, current_cart)
|
| 38 |
+
|
| 39 |
+
result = await agent.process_recipe_workflow(
|
| 40 |
+
user_id=user,
|
| 41 |
+
current_cart_slugs=slugs,
|
| 42 |
+
dish_query=query,
|
| 43 |
+
servings=srv,
|
| 44 |
+
chat_history=history,
|
| 45 |
+
current_cart=current_cart,
|
| 46 |
+
image_base64=payload.image_base64
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
if result.get("llm_unavailable"):
|
| 50 |
+
raise HTTPException(
|
| 51 |
+
status_code=503,
|
| 52 |
+
detail="LLM rate limit exceeded. Please try again later."
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
return result
|
| 56 |
+
except HTTPException:
|
| 57 |
+
raise
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print("\n=== CRITICAL API ROUTE ERROR TRACEBACK ===")
|
| 60 |
+
traceback.print_exc()
|
| 61 |
+
print("==========================================\n")
|
| 62 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get("/cart/{user_id}")
|
| 66 |
+
async def get_user_live_cart(user_id: str):
|
| 67 |
+
"""
|
| 68 |
+
Endpoint for Flutter client to fetch the active item quantities
|
| 69 |
+
that the AI agent added via tool calls.
|
| 70 |
+
"""
|
| 71 |
+
raw_cart_data = live_cart_memory.get_cart(user_id.lower().strip())
|
| 72 |
+
|
| 73 |
+
formatted_items = [
|
| 74 |
+
{"sku": sku, "quantity": qty}
|
| 75 |
+
for sku, qty in raw_cart_data.items()
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
return {
|
| 79 |
+
"user_id": user_id,
|
| 80 |
+
"items": formatted_items
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.post("/test-notification")
|
| 85 |
+
async def trigger_test_notification():
|
| 86 |
+
"""
|
| 87 |
+
Simulates a payment notification generation from the backend.
|
| 88 |
+
"""
|
| 89 |
+
import random
|
| 90 |
+
success = random.choice([True, False])
|
| 91 |
+
txn_id = f"pay_{''.join(random.choices('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', k=12))}" if success else ""
|
| 92 |
+
amount = random.choice([250.00, 499.00, 999.00, 1499.00, 2999.00])
|
| 93 |
+
|
| 94 |
+
if success:
|
| 95 |
+
message = f"Payment of INR {amount:,.2f} to Qless Merchant was successful."
|
| 96 |
+
else:
|
| 97 |
+
reason = random.choice([
|
| 98 |
+
"declined by the issuing bank",
|
| 99 |
+
"insufficient funds in the account",
|
| 100 |
+
"incorrect OTP entered",
|
| 101 |
+
"network timeout during processing"
|
| 102 |
+
])
|
| 103 |
+
message = f"Payment of INR {amount:,.2f} failed: {reason}."
|
| 104 |
+
|
| 105 |
+
return {
|
| 106 |
+
"success": success,
|
| 107 |
+
"message": message,
|
| 108 |
+
"transactionId": txn_id
|
| 109 |
+
}
|
app/routes/detection.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import io
|
| 3 |
+
import time
|
| 4 |
+
import datetime
|
| 5 |
+
from fastapi import APIRouter, File, UploadFile, Header
|
| 6 |
+
from fastapi.responses import FileResponse, HTMLResponse
|
| 7 |
+
from PIL import Image
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from ..config import (
|
| 11 |
+
IMAGES_DIR,
|
| 12 |
+
SIMILARITY_THRESHOLD,
|
| 13 |
+
ENABLE_OBJECT_DETECTION,
|
| 14 |
+
DETECTION_CONFIDENCE_THRESHOLD,
|
| 15 |
+
DETECTION_IOU_THRESHOLD,
|
| 16 |
+
MAX_DETECTIONS,
|
| 17 |
+
CROP_PADDING_RATIO,
|
| 18 |
+
)
|
| 19 |
+
from ..services.detector import processor, session, get_image_embedding
|
| 20 |
+
from ..services.chroma import ChromaSearcher
|
| 21 |
+
from ..services.supabase import SupabaseQuerier
|
| 22 |
+
|
| 23 |
+
if ENABLE_OBJECT_DETECTION:
|
| 24 |
+
from ..services.object_detector import detect_objects, crop_objects
|
| 25 |
+
|
| 26 |
+
router = APIRouter(tags=["Product Detection"])
|
| 27 |
+
|
| 28 |
+
@router.post("/embed")
|
| 29 |
+
async def get_embedding(file: UploadFile = File(...)):
|
| 30 |
+
"""Generate and return raw CLIP embeddings for the uploaded product image."""
|
| 31 |
+
try:
|
| 32 |
+
contents = await file.read()
|
| 33 |
+
embedding = get_image_embedding(contents)
|
| 34 |
+
return {"status": "success", "embedding": embedding}
|
| 35 |
+
except Exception as e:
|
| 36 |
+
return {"status": "error", "message": str(e)}
|
| 37 |
+
|
| 38 |
+
@router.post("/detect")
|
| 39 |
+
async def detect_item(
|
| 40 |
+
file: UploadFile = File(...),
|
| 41 |
+
x_chroma_token: str = Header(default=None),
|
| 42 |
+
x_supabase_url: str = Header(default=None),
|
| 43 |
+
x_supabase_key: str = Header(default=None)
|
| 44 |
+
):
|
| 45 |
+
"""
|
| 46 |
+
Perform full end-to-end product detection:
|
| 47 |
+
When ENABLE_OBJECT_DETECTION=true:
|
| 48 |
+
1. Run YOLO object detection to find bounding boxes.
|
| 49 |
+
2. Crop each detected object region.
|
| 50 |
+
3. Generate CLIP embeddings per crop.
|
| 51 |
+
4. Search ChromaDB for the best match across all crops.
|
| 52 |
+
Otherwise (legacy path):
|
| 53 |
+
1. Preprocess uploaded image and generate CLIP vector embeddings.
|
| 54 |
+
2. Search the vector catalog in ChromaDB for similarity matches.
|
| 55 |
+
3. Retrieve the matching item metadata from local catalog or Supabase DB.
|
| 56 |
+
"""
|
| 57 |
+
start_total = time.time()
|
| 58 |
+
try:
|
| 59 |
+
t0 = time.time()
|
| 60 |
+
contents = await file.read()
|
| 61 |
+
t_read = time.time() - t0
|
| 62 |
+
|
| 63 |
+
# -----------------------------------------------------------------
|
| 64 |
+
# Object-detection path: YOLO crop -> per-crop CLIP -> best match
|
| 65 |
+
# -----------------------------------------------------------------
|
| 66 |
+
if ENABLE_OBJECT_DETECTION:
|
| 67 |
+
t0 = time.time()
|
| 68 |
+
detections = detect_objects(
|
| 69 |
+
contents,
|
| 70 |
+
conf_threshold=DETECTION_CONFIDENCE_THRESHOLD,
|
| 71 |
+
iou_threshold=DETECTION_IOU_THRESHOLD,
|
| 72 |
+
max_detections=MAX_DETECTIONS,
|
| 73 |
+
)
|
| 74 |
+
t_detect = time.time() - t0
|
| 75 |
+
|
| 76 |
+
if not detections:
|
| 77 |
+
print(
|
| 78 |
+
f"[detect-obj] No objects detected (detect={t_detect:.4f}s, "
|
| 79 |
+
f"total={time.time() - start_total:.4f}s)"
|
| 80 |
+
)
|
| 81 |
+
return {
|
| 82 |
+
"status": "success",
|
| 83 |
+
"match_found": False,
|
| 84 |
+
"reason": "No objects detected in image",
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
t0 = time.time()
|
| 88 |
+
crops = crop_objects(contents, detections, padding_ratio=CROP_PADDING_RATIO)
|
| 89 |
+
t_crop = time.time() - t0
|
| 90 |
+
|
| 91 |
+
best_slug = None
|
| 92 |
+
best_distance = float("inf")
|
| 93 |
+
t_clip_total = 0.0
|
| 94 |
+
|
| 95 |
+
for i, crop_bytes in enumerate(crops):
|
| 96 |
+
t0 = time.time()
|
| 97 |
+
embedding = get_image_embedding(crop_bytes)
|
| 98 |
+
t_clip = time.time() - t0
|
| 99 |
+
t_clip_total += t_clip
|
| 100 |
+
|
| 101 |
+
searcher = ChromaSearcher(token=x_chroma_token)
|
| 102 |
+
search_result = await searcher.search(embedding)
|
| 103 |
+
if search_result is None:
|
| 104 |
+
continue
|
| 105 |
+
|
| 106 |
+
slug, distance = search_result
|
| 107 |
+
if distance <= SIMILARITY_THRESHOLD and distance < best_distance:
|
| 108 |
+
best_slug = slug
|
| 109 |
+
best_distance = distance
|
| 110 |
+
|
| 111 |
+
if best_slug is None:
|
| 112 |
+
print(
|
| 113 |
+
f"[detect-obj] No match found across {len(crops)} crops "
|
| 114 |
+
f"(detect={t_detect:.4f}s, crop={t_crop:.4f}s, "
|
| 115 |
+
f"clip={t_clip_total:.4f}s, total={time.time() - start_total:.4f}s)"
|
| 116 |
+
)
|
| 117 |
+
return {
|
| 118 |
+
"status": "success",
|
| 119 |
+
"match_found": False,
|
| 120 |
+
"reason": f"No match across {len(crops)} detected crops",
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
slug = best_slug
|
| 124 |
+
|
| 125 |
+
if x_supabase_url and x_supabase_key:
|
| 126 |
+
t0 = time.time()
|
| 127 |
+
querier = SupabaseQuerier(url=x_supabase_url, key=x_supabase_key)
|
| 128 |
+
product_data = await querier.get_product_by_slug(slug)
|
| 129 |
+
t_supabase = time.time() - t0
|
| 130 |
+
else:
|
| 131 |
+
product_data = None
|
| 132 |
+
t_supabase = 0.0
|
| 133 |
+
|
| 134 |
+
print(
|
| 135 |
+
f"[detect-obj] Profiling: Read={t_read:.4f}s, Detect={t_detect:.4f}s, "
|
| 136 |
+
f"Crop={t_crop:.4f}s, CLIP={t_clip_total:.4f}s, "
|
| 137 |
+
f"Supabase={t_supabase:.4f}s. "
|
| 138 |
+
f"Total={time.time() - start_total:.4f}s"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
if product_data:
|
| 142 |
+
return {
|
| 143 |
+
"status": "success",
|
| 144 |
+
"match_found": True,
|
| 145 |
+
"item": {
|
| 146 |
+
"sku": product_data.get("sku"),
|
| 147 |
+
"slug": product_data.get("slug"),
|
| 148 |
+
"name": product_data.get("name"),
|
| 149 |
+
"price_rupees": float(product_data.get("price_rupees", 0.0)),
|
| 150 |
+
},
|
| 151 |
+
}
|
| 152 |
+
else:
|
| 153 |
+
name_fallback = slug.replace("-", " ").upper()
|
| 154 |
+
return {
|
| 155 |
+
"status": "success",
|
| 156 |
+
"match_found": True,
|
| 157 |
+
"item": {
|
| 158 |
+
"sku": "UNLISTED",
|
| 159 |
+
"slug": slug,
|
| 160 |
+
"name": name_fallback,
|
| 161 |
+
"price_rupees": 0.0,
|
| 162 |
+
},
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
# -----------------------------------------------------------------
|
| 166 |
+
# Legacy whole-image CLIP path
|
| 167 |
+
# -----------------------------------------------------------------
|
| 168 |
+
t0 = time.time()
|
| 169 |
+
image = Image.open(io.BytesIO(contents)).convert("RGB")
|
| 170 |
+
image = image.resize((224, 224), Image.Resampling.BILINEAR)
|
| 171 |
+
inputs = processor(images=image, return_tensors="np")
|
| 172 |
+
pixel_values = inputs["pixel_values"]
|
| 173 |
+
t_preprocess = time.time() - t0
|
| 174 |
+
|
| 175 |
+
t0 = time.time()
|
| 176 |
+
outputs = session.run(["image_embeds"], {"pixel_values": pixel_values})
|
| 177 |
+
image_embeds = outputs[0]
|
| 178 |
+
t_onnx = time.time() - t0
|
| 179 |
+
|
| 180 |
+
norm = np.linalg.norm(image_embeds, axis=-1, keepdims=True)
|
| 181 |
+
normalized_image_embeds = image_embeds / (norm + 1e-12)
|
| 182 |
+
embedding = normalized_image_embeds[0].tolist()
|
| 183 |
+
|
| 184 |
+
t0 = time.time()
|
| 185 |
+
searcher = ChromaSearcher(token=x_chroma_token)
|
| 186 |
+
search_result = await searcher.search(embedding)
|
| 187 |
+
t_chroma = time.time() - t0
|
| 188 |
+
|
| 189 |
+
if search_result is None:
|
| 190 |
+
print(f"[detect] Finished (No ChromaDB Response) in {time.time() - start_total:.4f}s")
|
| 191 |
+
return {"status": "success", "match_found": False, "reason": "No ChromaDB response"}
|
| 192 |
+
|
| 193 |
+
slug, distance = search_result
|
| 194 |
+
if distance > SIMILARITY_THRESHOLD:
|
| 195 |
+
print(f"[detect] Distance {distance} exceeds threshold {SIMILARITY_THRESHOLD} for {slug} (Finished in {time.time() - start_total:.4f}s)")
|
| 196 |
+
return {"status": "success", "match_found": False, "reason": f"Distance {distance} exceeds threshold {SIMILARITY_THRESHOLD}"}
|
| 197 |
+
|
| 198 |
+
# Only query Supabase if the client requested it by sending credentials.
|
| 199 |
+
# Otherwise, skip to bypass database query latency and rely on client-side local lookup.
|
| 200 |
+
if x_supabase_url and x_supabase_key:
|
| 201 |
+
t0 = time.time()
|
| 202 |
+
querier = SupabaseQuerier(url=x_supabase_url, key=x_supabase_key)
|
| 203 |
+
product_data = await querier.get_product_by_slug(slug)
|
| 204 |
+
t_supabase = time.time() - t0
|
| 205 |
+
else:
|
| 206 |
+
product_data = None
|
| 207 |
+
t_supabase = 0.0
|
| 208 |
+
|
| 209 |
+
print(f"[detect] Profiling: Read={t_read:.4f}s, Preprocess={t_preprocess:.4f}s, ONNX={t_onnx:.4f}s, Chroma={t_chroma:.4f}s, Supabase={t_supabase:.4f}s. Total={time.time() - start_total:.4f}s")
|
| 210 |
+
|
| 211 |
+
if product_data:
|
| 212 |
+
return {
|
| 213 |
+
"status": "success",
|
| 214 |
+
"match_found": True,
|
| 215 |
+
"item": {
|
| 216 |
+
"sku": product_data.get("sku"),
|
| 217 |
+
"slug": product_data.get("slug"),
|
| 218 |
+
"name": product_data.get("name"),
|
| 219 |
+
"price_rupees": float(product_data.get("price_rupees", 0.0))
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
else:
|
| 223 |
+
name_fallback = slug.replace('-', ' ').upper()
|
| 224 |
+
return {
|
| 225 |
+
"status": "success",
|
| 226 |
+
"match_found": True,
|
| 227 |
+
"item": {
|
| 228 |
+
"sku": "UNLISTED",
|
| 229 |
+
"slug": slug,
|
| 230 |
+
"name": name_fallback,
|
| 231 |
+
"price_rupees": 0.0
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
except Exception as e:
|
| 235 |
+
print(f"[detect] Exception: {e}")
|
| 236 |
+
return {"status": "error", "message": str(e)}
|
| 237 |
+
|
| 238 |
+
@router.get("/captured_images/{filename}")
|
| 239 |
+
async def get_captured_image(filename: str):
|
| 240 |
+
"""Fetch a previously captured scan image from local disk storage."""
|
| 241 |
+
filepath = os.path.join(IMAGES_DIR, filename)
|
| 242 |
+
if os.path.exists(filepath):
|
| 243 |
+
return FileResponse(filepath)
|
| 244 |
+
return {"error": "File not found"}
|
| 245 |
+
|
| 246 |
+
@router.get("/gallery", response_class=HTMLResponse)
|
| 247 |
+
async def get_gallery():
|
| 248 |
+
"""Render a Scandinavian-modern product gallery page showcasing scanned items history."""
|
| 249 |
+
files = []
|
| 250 |
+
if os.path.exists(IMAGES_DIR):
|
| 251 |
+
for f in os.listdir(IMAGES_DIR):
|
| 252 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png')):
|
| 253 |
+
fp = os.path.join(IMAGES_DIR, f)
|
| 254 |
+
mtime = os.path.getmtime(fp)
|
| 255 |
+
files.append((f, mtime))
|
| 256 |
+
|
| 257 |
+
files.sort(key=lambda x: x[1], reverse=True)
|
| 258 |
+
|
| 259 |
+
# Render Scandinavian modern light themed gallery page
|
| 260 |
+
html_content = """<!DOCTYPE html>
|
| 261 |
+
<html>
|
| 262 |
+
<head>
|
| 263 |
+
<meta charset="utf-8">
|
| 264 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 265 |
+
<title>Scanned Products Gallery | QLESS</title>
|
| 266 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 267 |
+
<style>
|
| 268 |
+
body {
|
| 269 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
| 270 |
+
background-color: #F4F7F8;
|
| 271 |
+
color: #2D3748;
|
| 272 |
+
margin: 0;
|
| 273 |
+
padding: 40px 24px;
|
| 274 |
+
display: flex;
|
| 275 |
+
flex-direction: column;
|
| 276 |
+
align-items: center;
|
| 277 |
+
}
|
| 278 |
+
.container {
|
| 279 |
+
width: 100%;
|
| 280 |
+
max-width: 1100px;
|
| 281 |
+
}
|
| 282 |
+
header {
|
| 283 |
+
margin-bottom: 40px;
|
| 284 |
+
text-align: center;
|
| 285 |
+
}
|
| 286 |
+
h1 {
|
| 287 |
+
font-size: 2.25rem;
|
| 288 |
+
font-weight: 700;
|
| 289 |
+
color: #1A202C;
|
| 290 |
+
margin: 0 0 8px 0;
|
| 291 |
+
letter-spacing: -0.025em;
|
| 292 |
+
}
|
| 293 |
+
p {
|
| 294 |
+
color: #718096;
|
| 295 |
+
font-size: 1.1rem;
|
| 296 |
+
margin: 0;
|
| 297 |
+
}
|
| 298 |
+
.grid {
|
| 299 |
+
display: grid;
|
| 300 |
+
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
| 301 |
+
gap: 24px;
|
| 302 |
+
margin-top: 20px;
|
| 303 |
+
}
|
| 304 |
+
.card {
|
| 305 |
+
background: #FFFFFF;
|
| 306 |
+
border-radius: 18px;
|
| 307 |
+
overflow: hidden;
|
| 308 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03);
|
| 309 |
+
border: 1px solid rgba(0, 0, 0, 0.04);
|
| 310 |
+
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 311 |
+
}
|
| 312 |
+
.card:hover {
|
| 313 |
+
transform: translateY(-4px);
|
| 314 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.06);
|
| 315 |
+
}
|
| 316 |
+
.card-img-wrapper {
|
| 317 |
+
position: relative;
|
| 318 |
+
width: 100%;
|
| 319 |
+
padding-top: 100%; /* 1:1 Aspect Ratio */
|
| 320 |
+
background-color: #EDF2F7;
|
| 321 |
+
}
|
| 322 |
+
.card img {
|
| 323 |
+
position: absolute;
|
| 324 |
+
top: 0;
|
| 325 |
+
left: 0;
|
| 326 |
+
width: 100%;
|
| 327 |
+
height: 100%;
|
| 328 |
+
object-fit: cover;
|
| 329 |
+
}
|
| 330 |
+
.info {
|
| 331 |
+
padding: 16px;
|
| 332 |
+
font-size: 0.85rem;
|
| 333 |
+
color: #718096;
|
| 334 |
+
font-weight: 500;
|
| 335 |
+
text-align: center;
|
| 336 |
+
background: #FAFCFC;
|
| 337 |
+
border-top: 1px solid #E2E8F0;
|
| 338 |
+
}
|
| 339 |
+
.empty-state {
|
| 340 |
+
grid-column: 1 / -1;
|
| 341 |
+
text-align: center;
|
| 342 |
+
padding: 80px 20px;
|
| 343 |
+
background: #FFFFFF;
|
| 344 |
+
border-radius: 18px;
|
| 345 |
+
border: 1px dashed #E2E8F0;
|
| 346 |
+
color: #A0AEC0;
|
| 347 |
+
font-size: 1.1rem;
|
| 348 |
+
}
|
| 349 |
+
</style>
|
| 350 |
+
</head>
|
| 351 |
+
<body>
|
| 352 |
+
<div class="container">
|
| 353 |
+
<header>
|
| 354 |
+
<h1>Product Scan History</h1>
|
| 355 |
+
<p>A history of all product images captured by the AI Shopping Assistant.</p>
|
| 356 |
+
</header>
|
| 357 |
+
<div class="grid">
|
| 358 |
+
"""
|
| 359 |
+
|
| 360 |
+
for f, mtime in files:
|
| 361 |
+
dt = datetime.datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')
|
| 362 |
+
html_content += f"""
|
| 363 |
+
<div class="card">
|
| 364 |
+
<a href="/captured_images/{f}" target="_blank">
|
| 365 |
+
<div class="card-img-wrapper">
|
| 366 |
+
<img src="/captured_images/{f}" alt="Scan from {dt}">
|
| 367 |
+
</div>
|
| 368 |
+
</a>
|
| 369 |
+
<div class="info">{dt}</div>
|
| 370 |
+
</div>
|
| 371 |
+
"""
|
| 372 |
+
|
| 373 |
+
if not files:
|
| 374 |
+
html_content += """
|
| 375 |
+
<div class="empty-state">
|
| 376 |
+
No scanned images found yet. Start scanning from the app!
|
| 377 |
+
</div>
|
| 378 |
+
"""
|
| 379 |
+
|
| 380 |
+
html_content += """
|
| 381 |
+
</div>
|
| 382 |
+
</div>
|
| 383 |
+
</body>
|
| 384 |
+
</html>
|
| 385 |
+
"""
|
| 386 |
+
return html_content
|
| 387 |
+
|
| 388 |
+
|
app/routes/health.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from ..config import MODEL_ID, DEVICE
|
| 3 |
+
|
| 4 |
+
router = APIRouter(tags=["Health & Status"])
|
| 5 |
+
|
| 6 |
+
@router.api_route("/health", methods=["GET", "HEAD"])
|
| 7 |
+
def health_check():
|
| 8 |
+
"""Liveness probe to verify the server is running and accessible."""
|
| 9 |
+
return {"status": "ok"}
|
| 10 |
+
|
| 11 |
+
@router.get("/")
|
| 12 |
+
def read_root():
|
| 13 |
+
"""Retrieve basic server status metadata and active model details."""
|
| 14 |
+
return {
|
| 15 |
+
"status": "running",
|
| 16 |
+
"model": MODEL_ID,
|
| 17 |
+
"device": DEVICE
|
| 18 |
+
}
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Services package initialization
|
app/services/chroma.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import httpx
|
| 4 |
+
from .http_client import http_client
|
| 5 |
+
|
| 6 |
+
SLUG_STRIP_PATTERN = re.compile(r'-\d+$')
|
| 7 |
+
|
| 8 |
+
class ChromaSearcher:
|
| 9 |
+
def __init__(self, token: str = None):
|
| 10 |
+
self.api_key = token or os.environ.get("CHROMA_API_KEY", "")
|
| 11 |
+
self.tenant = "99526d4b-48cf-4b20-896b-0947aa36d4ab"
|
| 12 |
+
self.database = "QLESS"
|
| 13 |
+
self.collection_id = "c1102322-920e-4775-96c1-e324bdadaa1d"
|
| 14 |
+
self.url = f"https://api.trychroma.com/api/v2/tenants/{self.tenant}/databases/{self.database}/collections/{self.collection_id}/query"
|
| 15 |
+
|
| 16 |
+
async def search(self, embedding: list[float]) -> tuple[str, float] | None:
|
| 17 |
+
if not self.api_key:
|
| 18 |
+
print("[ChromaSearcher] Warning: No Chroma API key found.")
|
| 19 |
+
return None
|
| 20 |
+
|
| 21 |
+
headers = {
|
| 22 |
+
"x-chroma-token": self.api_key,
|
| 23 |
+
"Content-Type": "application/json"
|
| 24 |
+
}
|
| 25 |
+
payload = {
|
| 26 |
+
"query_embeddings": [embedding],
|
| 27 |
+
"n_results": 1
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
client = http_client if http_client is not None else httpx.AsyncClient()
|
| 31 |
+
close_client = http_client is None
|
| 32 |
+
try:
|
| 33 |
+
response = await client.post(self.url, headers=headers, json=payload, timeout=5.0)
|
| 34 |
+
if response.status_code == 200:
|
| 35 |
+
data = response.json()
|
| 36 |
+
metadatas = data.get("metadatas", [])
|
| 37 |
+
distances = data.get("distances", [])
|
| 38 |
+
|
| 39 |
+
if metadatas and metadatas[0] and metadatas[0][0]:
|
| 40 |
+
item_meta = metadatas[0][0]
|
| 41 |
+
raw_name = item_meta.get("product_name", "Unknown Item")
|
| 42 |
+
slug = self._normalize_slug(str(raw_name))
|
| 43 |
+
distance = distances[0][0] if (distances and distances[0]) else 2.0
|
| 44 |
+
return slug, float(distance)
|
| 45 |
+
else:
|
| 46 |
+
status = response.status_code
|
| 47 |
+
print(f"[ChromaSearcher] Query failed: {status} - {response.text}")
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"[ChromaSearcher] Error querying ChromaDB: {e}")
|
| 50 |
+
finally:
|
| 51 |
+
if close_client:
|
| 52 |
+
await client.aclose()
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
def _normalize_slug(self, slug: str) -> str:
|
| 56 |
+
clean_slug = SLUG_STRIP_PATTERN.sub('', slug)
|
| 57 |
+
mapping = {
|
| 58 |
+
'roasted-almond-chocolate-bar-cadbury': 'dairy-milk-roast-almond-cadbury',
|
| 59 |
+
'cadbury-dairy-milk-crispello': 'dairy-milk-crispello-cadbury',
|
| 60 |
+
'fruit-and-nut-milk-chocolate-bar-cadbury': 'dairy-milk-chocolate-cadbury',
|
| 61 |
+
}
|
| 62 |
+
return mapping.get(clean_slug, clean_slug)
|
app/services/detector.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import numpy as np
|
| 3 |
+
import onnxruntime as ort
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from huggingface_hub import hf_hub_download
|
| 6 |
+
from transformers import CLIPProcessor
|
| 7 |
+
from ..config import MODEL_ID, PROCESSOR_ID, INTRA_OP_NUM_THREADS, INTER_OP_NUM_THREADS, DEVICE
|
| 8 |
+
|
| 9 |
+
print(f"Loading CLIP processor '{PROCESSOR_ID}'...")
|
| 10 |
+
processor = CLIPProcessor.from_pretrained(PROCESSOR_ID)
|
| 11 |
+
|
| 12 |
+
print(f"Downloading ONNX model '{MODEL_ID}'...")
|
| 13 |
+
model_file = hf_hub_download(repo_id=MODEL_ID, filename="onnx/vision_model.onnx")
|
| 14 |
+
|
| 15 |
+
print("Initializing ONNX Runtime session...")
|
| 16 |
+
# Limit intra-op and inter-op threads to match environment core count (typically 2 on free space CPU)
|
| 17 |
+
ort_options = ort.SessionOptions()
|
| 18 |
+
ort_options.intra_op_num_threads = INTRA_OP_NUM_THREADS
|
| 19 |
+
ort_options.inter_op_num_threads = INTER_OP_NUM_THREADS
|
| 20 |
+
session = ort.InferenceSession(model_file, sess_options=ort_options, providers=["CPUExecutionProvider"])
|
| 21 |
+
print("Model loaded successfully!")
|
| 22 |
+
|
| 23 |
+
def get_image_embedding(contents: bytes) -> list[float]:
|
| 24 |
+
image = Image.open(io.BytesIO(contents)).convert("RGB")
|
| 25 |
+
image = image.resize((224, 224), Image.Resampling.BILINEAR)
|
| 26 |
+
inputs = processor(images=image, return_tensors="np")
|
| 27 |
+
pixel_values = inputs["pixel_values"]
|
| 28 |
+
|
| 29 |
+
outputs = session.run(["image_embeds"], {"pixel_values": pixel_values})
|
| 30 |
+
image_embeds = outputs[0]
|
| 31 |
+
|
| 32 |
+
norm = np.linalg.norm(image_embeds, axis=-1, keepdims=True)
|
| 33 |
+
normalized_image_embeds = image_embeds / (norm + 1e-12)
|
| 34 |
+
|
| 35 |
+
return normalized_image_embeds[0].tolist()
|
app/services/http_client.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
|
| 3 |
+
http_client: httpx.AsyncClient | None = None
|
| 4 |
+
|
| 5 |
+
async def init_http_client():
|
| 6 |
+
global http_client
|
| 7 |
+
http_client = httpx.AsyncClient()
|
| 8 |
+
|
| 9 |
+
async def close_http_client():
|
| 10 |
+
global http_client
|
| 11 |
+
if http_client:
|
| 12 |
+
await http_client.aclose()
|
| 13 |
+
http_client = None
|
app/services/nutrition_service.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import httpx
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class NutritionService:
|
| 9 |
+
def __init__(self):
|
| 10 |
+
self.api_key = os.getenv("USDA_API_KEY")
|
| 11 |
+
self.base_url = "https://api.nal.usda.gov/fdc/v1/foods/search"
|
| 12 |
+
|
| 13 |
+
def _score_food(self, food: dict, query: str) -> float:
|
| 14 |
+
from difflib import SequenceMatcher
|
| 15 |
+
desc = food.get("description", "").lower()
|
| 16 |
+
q_low = query.lower()
|
| 17 |
+
|
| 18 |
+
score = SequenceMatcher(None, q_low, desc).ratio()
|
| 19 |
+
|
| 20 |
+
q_tokens = [w.strip(",.()\"'").rstrip("s") for w in q_low.split() if w.strip()]
|
| 21 |
+
d_tokens = [w.strip(",.()\"'").rstrip("s") for w in desc.lower().split() if w.strip()]
|
| 22 |
+
|
| 23 |
+
if not q_tokens or not d_tokens:
|
| 24 |
+
return score
|
| 25 |
+
|
| 26 |
+
core_noun = q_tokens[-1]
|
| 27 |
+
adjectives = set(q_tokens[:-1])
|
| 28 |
+
|
| 29 |
+
if core_noun in d_tokens:
|
| 30 |
+
score += 3.0
|
| 31 |
+
else:
|
| 32 |
+
has_adjective_match = False
|
| 33 |
+
for adj in adjectives:
|
| 34 |
+
if adj in d_tokens:
|
| 35 |
+
has_adjective_match = True
|
| 36 |
+
if has_adjective_match:
|
| 37 |
+
score -= 4.0
|
| 38 |
+
|
| 39 |
+
for idx, token in enumerate(q_tokens):
|
| 40 |
+
if token in d_tokens:
|
| 41 |
+
weight = (idx + 1) / len(q_tokens)
|
| 42 |
+
score += weight * 1.5
|
| 43 |
+
|
| 44 |
+
d_first = desc.split(",")[0].strip().rstrip("s") if desc else ""
|
| 45 |
+
if d_first and d_first in q_tokens:
|
| 46 |
+
score += 1.0
|
| 47 |
+
|
| 48 |
+
if "raw" in desc:
|
| 49 |
+
score += 0.5
|
| 50 |
+
|
| 51 |
+
bad_keywords = ["patty", "chips", "rings", "fried", "powder", "salad", "frozen"]
|
| 52 |
+
if any(w in desc for w in bad_keywords):
|
| 53 |
+
score -= 3.0
|
| 54 |
+
|
| 55 |
+
return score
|
| 56 |
+
|
| 57 |
+
def _select_best_food(self, foods, query: str):
|
| 58 |
+
if not foods:
|
| 59 |
+
return None
|
| 60 |
+
return max(foods, key=lambda f: self._score_food(f, query))
|
| 61 |
+
|
| 62 |
+
async def get_nutrition(self, ingredient_name: str):
|
| 63 |
+
try:
|
| 64 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 65 |
+
response = await client.get(
|
| 66 |
+
self.base_url,
|
| 67 |
+
params={
|
| 68 |
+
"query": ingredient_name,
|
| 69 |
+
"pageSize": 10,
|
| 70 |
+
"api_key": self.api_key,
|
| 71 |
+
"dataType": "SR Legacy,Foundation",
|
| 72 |
+
},
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
response.raise_for_status()
|
| 76 |
+
|
| 77 |
+
data = response.json()
|
| 78 |
+
|
| 79 |
+
foods = data.get("foods", [])
|
| 80 |
+
|
| 81 |
+
if not foods:
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
food = self._select_best_food(foods, ingredient_name)
|
| 85 |
+
|
| 86 |
+
if not food:
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
print(
|
| 90 |
+
f"[NutritionService] '{ingredient_name}' matched to '{food.get('description')}'"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
nutrients = {
|
| 94 |
+
n["nutrientName"]: n["value"]
|
| 95 |
+
for n in food.get("foodNutrients", [])
|
| 96 |
+
if "value" in n
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
"calories": (
|
| 101 |
+
nutrients.get("Energy")
|
| 102 |
+
or nutrients.get("Energy (Atwater General Factors)")
|
| 103 |
+
or nutrients.get("Energy (Atwater Specific Factors)")
|
| 104 |
+
or 0
|
| 105 |
+
),
|
| 106 |
+
"protein": nutrients.get("Protein", 0),
|
| 107 |
+
"carbs": nutrients.get("Carbohydrate, by difference", 0),
|
| 108 |
+
"fat": nutrients.get("Total lipid (fat)", 0),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
except Exception as e:
|
| 112 |
+
print(f"[NutritionService] {ingredient_name}: {e}")
|
| 113 |
+
return None
|
| 114 |
+
def _safe_float(self, value):
|
| 115 |
+
try:
|
| 116 |
+
return float(value)
|
| 117 |
+
except Exception:
|
| 118 |
+
return 0.0
|
| 119 |
+
|
| 120 |
+
async def calculate_recipe_nutrition(
|
| 121 |
+
self,
|
| 122 |
+
ingredients,
|
| 123 |
+
servings,
|
| 124 |
+
):
|
| 125 |
+
total_calories = 0.0
|
| 126 |
+
total_protein = 0.0
|
| 127 |
+
total_carbs = 0.0
|
| 128 |
+
total_fat = 0.0
|
| 129 |
+
|
| 130 |
+
for ingredient in ingredients:
|
| 131 |
+
|
| 132 |
+
name = ingredient.get("name", "")
|
| 133 |
+
|
| 134 |
+
quantity = self._safe_float(
|
| 135 |
+
ingredient.get("quantity", 0)
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
unit = (
|
| 139 |
+
ingredient.get("unit", "")
|
| 140 |
+
.lower()
|
| 141 |
+
.strip()
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
print(f"CHECKING USDA FOR: {name}")
|
| 145 |
+
|
| 146 |
+
# TEMP DEBUG
|
| 147 |
+
# if unit not in ["g", "gm", "gram", "grams"]:
|
| 148 |
+
# continue
|
| 149 |
+
|
| 150 |
+
nutrition = await self.get_nutrition(name)
|
| 151 |
+
|
| 152 |
+
if not nutrition:
|
| 153 |
+
continue
|
| 154 |
+
|
| 155 |
+
multiplier = quantity / 100.0
|
| 156 |
+
|
| 157 |
+
total_calories += nutrition["calories"] * multiplier
|
| 158 |
+
total_protein += nutrition["protein"] * multiplier
|
| 159 |
+
total_carbs += nutrition["carbs"] * multiplier
|
| 160 |
+
total_fat += nutrition["fat"] * multiplier
|
| 161 |
+
|
| 162 |
+
servings = max(1, servings)
|
| 163 |
+
|
| 164 |
+
return {
|
| 165 |
+
"total": {
|
| 166 |
+
"calories": round(total_calories, 1),
|
| 167 |
+
"protein": round(total_protein, 1),
|
| 168 |
+
"carbs": round(total_carbs, 1),
|
| 169 |
+
"fat": round(total_fat, 1),
|
| 170 |
+
},
|
| 171 |
+
"per_serving": {
|
| 172 |
+
"calories": round(total_calories / servings, 1),
|
| 173 |
+
"protein": round(total_protein / servings, 1),
|
| 174 |
+
"carbs": round(total_carbs / servings, 1),
|
| 175 |
+
"fat": round(total_fat / servings, 1),
|
| 176 |
+
},
|
| 177 |
+
}
|
app/services/object_detector.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import numpy as np
|
| 3 |
+
import onnxruntime as ort
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from huggingface_hub import hf_hub_download
|
| 6 |
+
from ..config import (
|
| 7 |
+
YOLO_MODEL_ID,
|
| 8 |
+
YOLO_MODEL_FILENAME,
|
| 9 |
+
INTRA_OP_NUM_THREADS,
|
| 10 |
+
INTER_OP_NUM_THREADS,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
print(f"Loading YOLO detector '{YOLO_MODEL_ID}/{YOLO_MODEL_FILENAME}'...")
|
| 14 |
+
yolo_model_file = hf_hub_download(repo_id=YOLO_MODEL_ID, filename=YOLO_MODEL_FILENAME)
|
| 15 |
+
|
| 16 |
+
yolo_ort_options = ort.SessionOptions()
|
| 17 |
+
yolo_ort_options.intra_op_num_threads = INTRA_OP_NUM_THREADS
|
| 18 |
+
yolo_ort_options.inter_op_num_threads = INTER_OP_NUM_THREADS
|
| 19 |
+
yolo_session = ort.InferenceSession(
|
| 20 |
+
yolo_model_file, sess_options=yolo_ort_options, providers=["CPUExecutionProvider"]
|
| 21 |
+
)
|
| 22 |
+
print("YOLO detector loaded successfully!")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _letterbox(
|
| 26 |
+
img: Image.Image, new_shape: tuple[int, int] = (640, 640)
|
| 27 |
+
) -> tuple[Image.Image, float, tuple[int, int]]:
|
| 28 |
+
"""Resize image with letterboxing (preserve aspect ratio, pad with gray)."""
|
| 29 |
+
w, h = img.size
|
| 30 |
+
r = min(new_shape[0] / h, new_shape[1] / w)
|
| 31 |
+
new_unpad = (int(round(w * r)), int(round(h * r)))
|
| 32 |
+
dw = (new_shape[1] - new_unpad[0]) / 2
|
| 33 |
+
dh = (new_shape[0] - new_unpad[1]) / 2
|
| 34 |
+
|
| 35 |
+
resized = img.resize(new_unpad, Image.Resampling.BILINEAR)
|
| 36 |
+
|
| 37 |
+
top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
|
| 38 |
+
left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
|
| 39 |
+
padded = Image.new("RGB", (new_shape[1], new_shape[0]), (114, 114, 114))
|
| 40 |
+
padded.paste(resized, (left, top))
|
| 41 |
+
return padded, r, (dw, dh)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _nms(boxes: np.ndarray, scores: np.ndarray, iou_threshold: float) -> np.ndarray:
|
| 45 |
+
"""Non-Maximum Suppression. Returns indices of kept boxes."""
|
| 46 |
+
if len(boxes) == 0:
|
| 47 |
+
return np.array([], dtype=int)
|
| 48 |
+
|
| 49 |
+
order = scores.argsort()[::-1]
|
| 50 |
+
keep = []
|
| 51 |
+
|
| 52 |
+
while len(order) > 0:
|
| 53 |
+
i = order[0]
|
| 54 |
+
keep.append(i)
|
| 55 |
+
if len(order) == 1:
|
| 56 |
+
break
|
| 57 |
+
|
| 58 |
+
xx1 = np.maximum(boxes[i, 0], boxes[order[1:], 0])
|
| 59 |
+
yy1 = np.maximum(boxes[i, 1], boxes[order[1:], 1])
|
| 60 |
+
xx2 = np.minimum(boxes[i, 2], boxes[order[1:], 2])
|
| 61 |
+
yy2 = np.minimum(boxes[i, 3], boxes[order[1:], 3])
|
| 62 |
+
|
| 63 |
+
inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1)
|
| 64 |
+
area_i = (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
|
| 65 |
+
area_rest = (boxes[order[1:], 2] - boxes[order[1:], 0]) * (
|
| 66 |
+
boxes[order[1:], 3] - boxes[order[1:], 1]
|
| 67 |
+
)
|
| 68 |
+
iou = inter / (area_i + area_rest - inter + 1e-7)
|
| 69 |
+
|
| 70 |
+
inds = np.where(iou <= iou_threshold)[0]
|
| 71 |
+
order = order[inds + 1]
|
| 72 |
+
|
| 73 |
+
return np.array(keep, dtype=int)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _preprocess(contents: bytes) -> tuple[np.ndarray, float, tuple[int, int], tuple[int, int]]:
|
| 77 |
+
"""Decode image, letterbox to 640x640, return NCHW float32 tensor + metadata."""
|
| 78 |
+
image = Image.open(io.BytesIO(contents)).convert("RGB")
|
| 79 |
+
orig_w, orig_h = image.size
|
| 80 |
+
|
| 81 |
+
img, ratio, (dw, dh) = _letterbox(image)
|
| 82 |
+
arr = np.array(img, dtype=np.float32) / 255.0
|
| 83 |
+
arr = arr.transpose(2, 0, 1) # HWC -> CHW
|
| 84 |
+
arr = np.expand_dims(arr, 0) # add batch dim -> NCHW
|
| 85 |
+
return arr, ratio, (dw, dh), (orig_w, orig_h)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _postprocess(
|
| 89 |
+
output: np.ndarray,
|
| 90 |
+
conf_threshold: float,
|
| 91 |
+
iou_threshold: float,
|
| 92 |
+
ratio: float,
|
| 93 |
+
pad: tuple[int, int],
|
| 94 |
+
orig_size: tuple[int, int],
|
| 95 |
+
max_detections: int,
|
| 96 |
+
) -> list[dict]:
|
| 97 |
+
"""Parse YOLO output tensor, apply NMS, return list of detections."""
|
| 98 |
+
# output shape: (1, 84, 8400) -> transpose to (8400, 84)
|
| 99 |
+
preds = output[0].T # (8400, 84)
|
| 100 |
+
|
| 101 |
+
boxes_xywh = preds[:, :4]
|
| 102 |
+
class_scores = preds[:, 4:]
|
| 103 |
+
max_scores = class_scores.max(axis=1)
|
| 104 |
+
class_ids = class_scores.argmax(axis=1)
|
| 105 |
+
|
| 106 |
+
# Filter by confidence
|
| 107 |
+
mask = max_scores > conf_threshold
|
| 108 |
+
boxes_xywh = boxes_xywh[mask]
|
| 109 |
+
max_scores = max_scores[mask]
|
| 110 |
+
class_ids = class_ids[mask]
|
| 111 |
+
|
| 112 |
+
if len(max_scores) == 0:
|
| 113 |
+
return []
|
| 114 |
+
|
| 115 |
+
# Convert xywh -> xyxy
|
| 116 |
+
x1 = boxes_xywh[:, 0] - boxes_xywh[:, 2] / 2
|
| 117 |
+
y1 = boxes_xywh[:, 1] - boxes_xywh[:, 3] / 2
|
| 118 |
+
x2 = boxes_xywh[:, 0] + boxes_xywh[:, 2] / 2
|
| 119 |
+
y2 = boxes_xywh[:, 1] + boxes_xywh[:, 3] / 2
|
| 120 |
+
boxes_xyxy = np.stack([x1, y1, x2, y2], axis=1)
|
| 121 |
+
|
| 122 |
+
# NMS
|
| 123 |
+
keep = _nms(boxes_xyxy, max_scores, iou_threshold)
|
| 124 |
+
|
| 125 |
+
# Take top max_detections
|
| 126 |
+
if len(keep) > max_detections:
|
| 127 |
+
keep = keep[:max_detections]
|
| 128 |
+
|
| 129 |
+
# Scale back to original image coordinates (undo letterbox padding + ratio)
|
| 130 |
+
dw, dh = pad
|
| 131 |
+
orig_w, orig_h = orig_size
|
| 132 |
+
detections = []
|
| 133 |
+
for idx in keep:
|
| 134 |
+
bx1, by1, bx2, by2 = boxes_xyxy[idx]
|
| 135 |
+
# Undo letterbox padding
|
| 136 |
+
bx1 = (bx1 - dw) / ratio
|
| 137 |
+
by1 = (by1 - dh) / ratio
|
| 138 |
+
bx2 = (bx2 - dw) / ratio
|
| 139 |
+
by2 = (by2 - dh) / ratio
|
| 140 |
+
# Clamp to original image bounds
|
| 141 |
+
bx1 = max(0, min(bx1, orig_w))
|
| 142 |
+
by1 = max(0, min(by1, orig_h))
|
| 143 |
+
bx2 = max(0, min(bx2, orig_w))
|
| 144 |
+
by2 = max(0, min(by2, orig_h))
|
| 145 |
+
detections.append(
|
| 146 |
+
{
|
| 147 |
+
"bbox": [float(bx1), float(by1), float(bx2), float(by2)],
|
| 148 |
+
"confidence": float(max_scores[idx]),
|
| 149 |
+
"class_id": int(class_ids[idx]),
|
| 150 |
+
}
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
return detections
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def detect_objects(
|
| 157 |
+
contents: bytes,
|
| 158 |
+
conf_threshold: float = 0.25,
|
| 159 |
+
iou_threshold: float = 0.45,
|
| 160 |
+
max_detections: int = 3,
|
| 161 |
+
) -> list[dict]:
|
| 162 |
+
"""Run YOLO detection on image bytes. Returns list of detections sorted by confidence."""
|
| 163 |
+
pixel_values, ratio, pad, orig_size = _preprocess(contents)
|
| 164 |
+
input_name = yolo_session.get_inputs()[0].name
|
| 165 |
+
output_name = yolo_session.get_outputs()[0].name
|
| 166 |
+
raw_output = yolo_session.run([output_name], {input_name: pixel_values})[0]
|
| 167 |
+
detections = _postprocess(
|
| 168 |
+
raw_output, conf_threshold, iou_threshold, ratio, pad, orig_size, max_detections
|
| 169 |
+
)
|
| 170 |
+
return detections
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def crop_objects(
|
| 174 |
+
contents: bytes, detections: list[dict], padding_ratio: float = 0.10
|
| 175 |
+
) -> list[bytes]:
|
| 176 |
+
"""Crop detected bounding box regions from the original image. Returns list of JPEG bytes."""
|
| 177 |
+
image = Image.open(io.BytesIO(contents)).convert("RGB")
|
| 178 |
+
orig_w, orig_h = image.size
|
| 179 |
+
crops = []
|
| 180 |
+
|
| 181 |
+
for det in detections:
|
| 182 |
+
x1, y1, x2, y2 = det["bbox"]
|
| 183 |
+
bw, bh = x2 - x1, y2 - y1
|
| 184 |
+
pad_x, pad_y = bw * padding_ratio, bh * padding_ratio
|
| 185 |
+
|
| 186 |
+
cx1 = max(0, int(x1 - pad_x))
|
| 187 |
+
cy1 = max(0, int(y1 - pad_y))
|
| 188 |
+
cx2 = min(orig_w, int(x2 + pad_x))
|
| 189 |
+
cy2 = min(orig_h, int(y2 + pad_y))
|
| 190 |
+
|
| 191 |
+
if cx2 <= cx1 or cy2 <= cy1:
|
| 192 |
+
continue
|
| 193 |
+
|
| 194 |
+
crop = image.crop((cx1, cy1, cx2, cy2))
|
| 195 |
+
buf = io.BytesIO()
|
| 196 |
+
crop.save(buf, format="JPEG", quality=95)
|
| 197 |
+
crops.append(buf.getvalue())
|
| 198 |
+
|
| 199 |
+
return crops
|
app/services/quantity_normalizer_service.py
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
QuantityNormalizerService
|
| 3 |
+
=========================
|
| 4 |
+
Converts recipe cooking-unit ingredients β grams using USDA FoodData Central.
|
| 5 |
+
|
| 6 |
+
Flow per ingredient:
|
| 7 |
+
1. Search /v1/foods/search β fdcId
|
| 8 |
+
2. Fetch /v1/food/{fdcId} β foodPortions + foodMeasures
|
| 9 |
+
3. Match cooking unit to a USDA portion entry
|
| 10 |
+
4. Return grams
|
| 11 |
+
|
| 12 |
+
Design constraints honoured:
|
| 13 |
+
β No hardcoded ingredient conversion tables
|
| 14 |
+
β No extra LLM calls
|
| 15 |
+
β USDA is the single source of truth
|
| 16 |
+
β Returns None (never 0) when conversion impossible β caller skips
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
import logging
|
| 21 |
+
from difflib import SequenceMatcher
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
import httpx # already in your stack; swap for aiohttp if preferred
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# USDA base URL β single constant, easy to mock in tests
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
USDA_BASE = "https://api.nal.usda.gov/fdc/v1"
|
| 32 |
+
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
# Unit alias table
|
| 35 |
+
# Purpose: normalise user-facing cooking strings β a canonical key.
|
| 36 |
+
# These are UNIT NAME normalisations only β no gram values here.
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
UNIT_ALIASES: dict[str, str] = {
|
| 39 |
+
# cups
|
| 40 |
+
"cup": "cup", "cups": "cup", "c": "cup", "c.": "cup",
|
| 41 |
+
# tablespoons
|
| 42 |
+
"tablespoon": "tablespoon", "tablespoons": "tablespoon",
|
| 43 |
+
"tbsp": "tablespoon", "tbsp.": "tablespoon", "tbs": "tablespoon",
|
| 44 |
+
"tb": "tablespoon",
|
| 45 |
+
# teaspoons
|
| 46 |
+
"teaspoon": "teaspoon", "teaspoons": "teaspoon",
|
| 47 |
+
"tsp": "teaspoon", "tsp.": "teaspoon",
|
| 48 |
+
# mass β handled directly, no USDA lookup needed
|
| 49 |
+
"gram": "gram", "grams": "gram", "g": "gram", "g.": "gram",
|
| 50 |
+
"kilogram": "kilogram", "kilograms": "kilogram", "kg": "kilogram",
|
| 51 |
+
# imperial mass
|
| 52 |
+
"ounce": "ounce", "ounces": "ounce", "oz": "ounce", "oz.": "ounce",
|
| 53 |
+
"pound": "pound", "pounds": "pound", "lb": "pound",
|
| 54 |
+
"lbs": "pound", "lb.": "pound",
|
| 55 |
+
# volume (liquid)
|
| 56 |
+
"milliliter": "milliliter", "milliliters": "milliliter",
|
| 57 |
+
"ml": "milliliter", "ml.": "milliliter",
|
| 58 |
+
"liter": "liter", "liters": "liter", "l": "liter",
|
| 59 |
+
"fluid ounce": "fluid_ounce", "fl oz": "fluid_ounce",
|
| 60 |
+
"fl. oz.": "fluid_ounce",
|
| 61 |
+
# cooking pieces β all resolved via USDA foodPortions
|
| 62 |
+
"clove": "clove", "cloves": "clove",
|
| 63 |
+
"piece": "piece", "pieces": "piece",
|
| 64 |
+
"whole": "whole",
|
| 65 |
+
"small": "small",
|
| 66 |
+
"medium": "medium",
|
| 67 |
+
"large": "large",
|
| 68 |
+
"extra large": "extra_large", "xl": "extra_large",
|
| 69 |
+
"slice": "slice", "slices": "slice",
|
| 70 |
+
"sprig": "sprig", "sprigs": "sprig",
|
| 71 |
+
"stalk": "stalk", "stalks": "stalk",
|
| 72 |
+
"bunch": "bunch", "bunches": "bunch",
|
| 73 |
+
"head": "head", "heads": "head",
|
| 74 |
+
"handful": "handful",
|
| 75 |
+
"can": "can", "cans": "can",
|
| 76 |
+
"package": "package", "packages": "package", "pkg": "package",
|
| 77 |
+
"strip": "strip", "strips": "strip",
|
| 78 |
+
"fillet": "fillet", "fillets": "fillet",
|
| 79 |
+
"breast": "breast", "thigh": "thigh", "leg": "leg",
|
| 80 |
+
"pinch": "pinch", "dash": "dash",
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# SI fallback β for mass/volume units that need no ingredient-specific data.
|
| 85 |
+
# Used ONLY when USDA returns nothing useful.
|
| 86 |
+
# ---------------------------------------------------------------------------
|
| 87 |
+
SI_GRAMS: dict[str, float] = {
|
| 88 |
+
"gram": 1.0,
|
| 89 |
+
"kilogram": 1000.0,
|
| 90 |
+
"ounce": 28.3495,
|
| 91 |
+
"pound": 453.592,
|
| 92 |
+
"milliliter": 1.0, # water-density assumption; fine for oils/broths
|
| 93 |
+
"liter": 1000.0,
|
| 94 |
+
"fluid_ounce": 29.5735,
|
| 95 |
+
"tablespoon": 14.7868, # last-resort SI for liquids only
|
| 96 |
+
"teaspoon": 4.9289, # last-resort SI for liquids only
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
# ---------------------------------------------------------------------------
|
| 100 |
+
# Negligible units β nutritionally irrelevant; skip cleanly without warning
|
| 101 |
+
# ---------------------------------------------------------------------------
|
| 102 |
+
SKIP_UNITS = {"pinch", "dash", "to taste", "as needed", "a pinch"}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ===========================================================================
|
| 106 |
+
class QuantityNormalizerService:
|
| 107 |
+
"""
|
| 108 |
+
Async service. Instantiate once, reuse across requests (shares httpx client).
|
| 109 |
+
|
| 110 |
+
Usage:
|
| 111 |
+
async with QuantityNormalizerService(api_key="...") as normalizer:
|
| 112 |
+
result = await normalizer.normalize_ingredient({
|
| 113 |
+
"name": "Basmati Rice",
|
| 114 |
+
"quantity": "1",
|
| 115 |
+
"unit": "cup"
|
| 116 |
+
})
|
| 117 |
+
"""
|
| 118 |
+
|
| 119 |
+
def __init__(self, api_key: str):
|
| 120 |
+
self._api_key = api_key
|
| 121 |
+
self._client: Optional[httpx.AsyncClient] = None
|
| 122 |
+
|
| 123 |
+
# ------------------------------------------------------------------
|
| 124 |
+
# Context manager β keeps a single httpx session alive
|
| 125 |
+
# ------------------------------------------------------------------
|
| 126 |
+
async def __aenter__(self):
|
| 127 |
+
self._client = httpx.AsyncClient(
|
| 128 |
+
timeout=httpx.Timeout(10.0),
|
| 129 |
+
params={"api_key": self._api_key},
|
| 130 |
+
)
|
| 131 |
+
return self
|
| 132 |
+
|
| 133 |
+
async def __aexit__(self, *_):
|
| 134 |
+
if self._client:
|
| 135 |
+
await self._client.aclose()
|
| 136 |
+
|
| 137 |
+
# ==================================================================
|
| 138 |
+
# Public API
|
| 139 |
+
# ==================================================================
|
| 140 |
+
|
| 141 |
+
async def normalize_ingredient(self, ingredient: dict) -> Optional[dict]:
|
| 142 |
+
"""
|
| 143 |
+
Convert one ingredient dict to grams.
|
| 144 |
+
|
| 145 |
+
Input:
|
| 146 |
+
{"name": "Garlic", "quantity": "2", "unit": "cloves"}
|
| 147 |
+
|
| 148 |
+
Output (success):
|
| 149 |
+
{
|
| 150 |
+
"name": "Garlic",
|
| 151 |
+
"original_quantity": "2 cloves",
|
| 152 |
+
"quantity": 6.0,
|
| 153 |
+
"unit": "g"
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
Output (failure):
|
| 157 |
+
None β caller skips this ingredient, reason already logged
|
| 158 |
+
"""
|
| 159 |
+
name = ingredient.get("name", "").strip()
|
| 160 |
+
raw_quantity = str(ingredient.get("quantity", "0")).strip()
|
| 161 |
+
raw_unit = str(ingredient.get("unit", "")).strip()
|
| 162 |
+
|
| 163 |
+
if not name:
|
| 164 |
+
logger.warning("normalize_ingredient: ingredient has no name β skipping")
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
quantity = self._parse_quantity(raw_quantity)
|
| 168 |
+
if quantity is None or quantity <= 0:
|
| 169 |
+
logger.warning(
|
| 170 |
+
"[%s] quantity '%s' could not be parsed β skipping", name, raw_quantity
|
| 171 |
+
)
|
| 172 |
+
return None
|
| 173 |
+
|
| 174 |
+
canonical_unit = self._canonicalize(raw_unit)
|
| 175 |
+
|
| 176 |
+
# Negligible units β skip without noise
|
| 177 |
+
if canonical_unit in SKIP_UNITS:
|
| 178 |
+
logger.info("[%s] unit '%s' is negligible β skipping", name, raw_unit)
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
original = f"{raw_quantity} {raw_unit}".strip()
|
| 182 |
+
logger.debug("[%s] normalising: %s", name, original)
|
| 183 |
+
|
| 184 |
+
# ββ Path 1: already in grams / kg ββββββββββββββββββββββββββββββββββ
|
| 185 |
+
if canonical_unit in ("gram", "kilogram"):
|
| 186 |
+
grams = quantity * SI_GRAMS[canonical_unit]
|
| 187 |
+
return self._result(name, original, grams)
|
| 188 |
+
|
| 189 |
+
# ββ Path 2: USDA lookup βββββββββββββββββββββββββββββββββββββββββββββ
|
| 190 |
+
grams = await self._usda_convert(name, quantity, canonical_unit, raw_unit)
|
| 191 |
+
if grams is not None:
|
| 192 |
+
return self._result(name, original, grams)
|
| 193 |
+
|
| 194 |
+
# ββ Path 3: SI fallback (oz, lb, ml, l, fl oz) βββββββββββββββββββββ
|
| 195 |
+
if canonical_unit in SI_GRAMS:
|
| 196 |
+
grams = quantity * SI_GRAMS[canonical_unit]
|
| 197 |
+
logger.info(
|
| 198 |
+
"[%s] SI fallback: %s %s β %.2fg", name, quantity, raw_unit, grams
|
| 199 |
+
)
|
| 200 |
+
return self._result(name, original, grams)
|
| 201 |
+
|
| 202 |
+
# ββ No conversion possible ββββββββββββββββββββββββββββββββββββββββββ
|
| 203 |
+
logger.warning(
|
| 204 |
+
"[%s] SKIP β cannot convert '%s %s': "
|
| 205 |
+
"no USDA portion match and no SI fallback for this unit.",
|
| 206 |
+
name, quantity, raw_unit,
|
| 207 |
+
)
|
| 208 |
+
return None
|
| 209 |
+
|
| 210 |
+
async def normalize_ingredients(
|
| 211 |
+
self, ingredients: list[dict]
|
| 212 |
+
) -> tuple[list[dict], list[dict]]:
|
| 213 |
+
"""
|
| 214 |
+
Normalise a list of ingredients.
|
| 215 |
+
|
| 216 |
+
Returns (normalised, skipped) β two separate lists so the caller
|
| 217 |
+
can log or surface skipped items to the user.
|
| 218 |
+
"""
|
| 219 |
+
import asyncio
|
| 220 |
+
tasks = [self.normalize_ingredient(i) for i in ingredients]
|
| 221 |
+
results = await asyncio.gather(*tasks, return_exceptions=False)
|
| 222 |
+
|
| 223 |
+
normalised, skipped = [], []
|
| 224 |
+
for original, result in zip(ingredients, results):
|
| 225 |
+
if result is None:
|
| 226 |
+
skipped.append(original)
|
| 227 |
+
else:
|
| 228 |
+
normalised.append(result)
|
| 229 |
+
|
| 230 |
+
print("\n===== NORMALIZED INGREDIENTS =====")
|
| 231 |
+
for item in normalised:
|
| 232 |
+
print(item)
|
| 233 |
+
|
| 234 |
+
print("\n===== SKIPPED INGREDIENTS =====")
|
| 235 |
+
for item in skipped:
|
| 236 |
+
print(item)
|
| 237 |
+
|
| 238 |
+
print("=================================\n")
|
| 239 |
+
|
| 240 |
+
return normalised, skipped
|
| 241 |
+
|
| 242 |
+
# ==================================================================
|
| 243 |
+
# USDA logic
|
| 244 |
+
# ==================================================================
|
| 245 |
+
|
| 246 |
+
async def _usda_convert(
|
| 247 |
+
self,
|
| 248 |
+
name: str,
|
| 249 |
+
quantity: float,
|
| 250 |
+
canonical_unit: str,
|
| 251 |
+
raw_unit: str,
|
| 252 |
+
) -> Optional[float]:
|
| 253 |
+
"""
|
| 254 |
+
Full USDA two-step:
|
| 255 |
+
1. Search β fdcId
|
| 256 |
+
2. Food detail β foodPortions + foodMeasures
|
| 257 |
+
3. Match unit β gramWeight
|
| 258 |
+
"""
|
| 259 |
+
fdc_id = await self._search_fdc_id(name)
|
| 260 |
+
if fdc_id is None:
|
| 261 |
+
logger.warning("[%s] USDA search returned no results", name)
|
| 262 |
+
return None
|
| 263 |
+
|
| 264 |
+
portions, measures = await self._fetch_portions(fdc_id, name)
|
| 265 |
+
|
| 266 |
+
# Try foodPortions first (richer, from /food/{fdcId})
|
| 267 |
+
gw = self._match_portions(canonical_unit, portions)
|
| 268 |
+
if gw is not None:
|
| 269 |
+
result = quantity * gw
|
| 270 |
+
logger.debug(
|
| 271 |
+
"[%s] foodPortions match: %s %s β %.2fg (%.4g g/unit)",
|
| 272 |
+
name, quantity, raw_unit, result, gw,
|
| 273 |
+
)
|
| 274 |
+
return result
|
| 275 |
+
|
| 276 |
+
# Fall back to foodMeasures (also present on detail endpoint)
|
| 277 |
+
gw = self._match_measures(canonical_unit, measures)
|
| 278 |
+
if gw is not None:
|
| 279 |
+
result = quantity * gw
|
| 280 |
+
logger.debug(
|
| 281 |
+
"[%s] foodMeasures match: %s %s β %.2fg (%.4g g/unit)",
|
| 282 |
+
name, quantity, raw_unit, result, gw,
|
| 283 |
+
)
|
| 284 |
+
return result
|
| 285 |
+
|
| 286 |
+
logger.info(
|
| 287 |
+
"[%s] fdcId=%s β no portion/measure match for unit '%s'. "
|
| 288 |
+
"Available: portions=%s measures=%s",
|
| 289 |
+
name, fdc_id,
|
| 290 |
+
canonical_unit,
|
| 291 |
+
[p.get("modifier","") or p.get("measureUnit",{}).get("name","") for p in portions],
|
| 292 |
+
[m.get("disseminationText","") for m in measures],
|
| 293 |
+
)
|
| 294 |
+
return None
|
| 295 |
+
|
| 296 |
+
async def _search_fdc_id(self, query: str) -> Optional[int]:
|
| 297 |
+
"""Search USDA, return fdcId of best SR Legacy / Foundation match.
|
| 298 |
+
|
| 299 |
+
Uses pageSize=100 and scores results so that SR Legacy/Foundation entries for
|
| 300 |
+
raw/whole foods rank above processed or branded items that happen
|
| 301 |
+
to share a keyword (e.g. "Rice crackers" vs "Rice, white, raw").
|
| 302 |
+
"""
|
| 303 |
+
# Generic query sanitization: regex-remove parenthetical text and trim
|
| 304 |
+
query = re.sub(r"\(.*?\)", "", query).strip()
|
| 305 |
+
query = re.sub(r"\s+", " ", query)
|
| 306 |
+
|
| 307 |
+
_PROCESSED_KW = {
|
| 308 |
+
"cracker", "snack", "cake", "cookie", "chip", "mix", "beverage",
|
| 309 |
+
"soup", "sauce", "pudding", "babyfood", "ring", "frozen", "fried",
|
| 310 |
+
"powder", "flake", "dehydrated", "pickled", "canned", "bread",
|
| 311 |
+
"breadstick", "sausage",
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
def _score(food: dict, q_str: str) -> float:
|
| 315 |
+
desc = food.get("description", "").lower()
|
| 316 |
+
q_low = q_str.lower()
|
| 317 |
+
sc = SequenceMatcher(None, q_low, desc).ratio()
|
| 318 |
+
# Bonus: description starts with a query word ("Onions, raw" for "Onion")
|
| 319 |
+
q_stems = {w.rstrip("s") for w in q_low.split()}
|
| 320 |
+
d_first = desc.split(",")[0].strip().rstrip("s") if desc else ""
|
| 321 |
+
if d_first and d_first in q_stems:
|
| 322 |
+
sc += 1.0
|
| 323 |
+
if "," in desc[:15]: # "Onions, raw" pattern β tightly scoped entry
|
| 324 |
+
sc += 2.0
|
| 325 |
+
# Bonus: raw / whole food
|
| 326 |
+
if any(w in desc for w in ("raw", "fresh", "uncooked")):
|
| 327 |
+
sc += 0.5
|
| 328 |
+
# SR Legacy has richer foodPortions than Foundation
|
| 329 |
+
if food.get("dataType") == "SR Legacy":
|
| 330 |
+
sc += 2.0
|
| 331 |
+
# Penalty: processed / packaged food
|
| 332 |
+
for kw in _PROCESSED_KW:
|
| 333 |
+
if kw in desc:
|
| 334 |
+
sc -= 3.0
|
| 335 |
+
break
|
| 336 |
+
return sc
|
| 337 |
+
|
| 338 |
+
# Formulate search queries: try appending "raw" first if not specified
|
| 339 |
+
queries = [query]
|
| 340 |
+
q_low = query.lower()
|
| 341 |
+
if not any(w in q_low for w in ("raw", "fresh", "uncooked", "cooked", "dry", "canned", "powder")):
|
| 342 |
+
queries.insert(0, f"{query} raw")
|
| 343 |
+
|
| 344 |
+
for q in queries:
|
| 345 |
+
try:
|
| 346 |
+
resp = await self._client.get(
|
| 347 |
+
f"{USDA_BASE}/foods/search",
|
| 348 |
+
params={
|
| 349 |
+
"query": q,
|
| 350 |
+
"dataType": "SR Legacy,Foundation",
|
| 351 |
+
"pageSize": 100,
|
| 352 |
+
},
|
| 353 |
+
)
|
| 354 |
+
resp.raise_for_status()
|
| 355 |
+
foods = resp.json().get("foods", [])
|
| 356 |
+
if foods:
|
| 357 |
+
best = max(foods, key=lambda f: _score(f, q))
|
| 358 |
+
return best.get("fdcId")
|
| 359 |
+
except httpx.HTTPError as exc:
|
| 360 |
+
logger.error("[%s] USDA search HTTP error for query '%s': %s", query, q, exc)
|
| 361 |
+
return None
|
| 362 |
+
|
| 363 |
+
async def _fetch_portions(
|
| 364 |
+
self, fdc_id: int, name: str
|
| 365 |
+
) -> tuple[list[dict], list[dict]]:
|
| 366 |
+
"""
|
| 367 |
+
Fetch /v1/food/{fdcId}.
|
| 368 |
+
Returns (foodPortions, foodMeasures) β both may be empty lists.
|
| 369 |
+
"""
|
| 370 |
+
try:
|
| 371 |
+
resp = await self._client.get(f"{USDA_BASE}/food/{fdc_id}")
|
| 372 |
+
resp.raise_for_status()
|
| 373 |
+
data = resp.json()
|
| 374 |
+
portions = data.get("foodPortions", [])
|
| 375 |
+
measures = data.get("foodMeasures", [])
|
| 376 |
+
logger.debug(
|
| 377 |
+
"[%s] fdcId=%s portions=%d measures=%d",
|
| 378 |
+
name, fdc_id, len(portions), len(measures),
|
| 379 |
+
)
|
| 380 |
+
return portions, measures
|
| 381 |
+
except httpx.HTTPError as exc:
|
| 382 |
+
logger.error("[%s] USDA detail HTTP error (fdcId=%s): %s", name, fdc_id, exc)
|
| 383 |
+
return [], []
|
| 384 |
+
|
| 385 |
+
# ==================================================================
|
| 386 |
+
# Matching logic
|
| 387 |
+
# ==================================================================
|
| 388 |
+
|
| 389 |
+
def _match_portions(
|
| 390 |
+
self, canonical_unit: str, portions: list[dict]
|
| 391 |
+
) -> Optional[float]:
|
| 392 |
+
"""
|
| 393 |
+
Match against foodPortions (from /food/{fdcId}).
|
| 394 |
+
|
| 395 |
+
foodPortions entry shape:
|
| 396 |
+
{
|
| 397 |
+
"id": 123,
|
| 398 |
+
"amount": 1.0,
|
| 399 |
+
"gramWeight": 186.0,
|
| 400 |
+
"modifier": "1 cup", β free-text description
|
| 401 |
+
"measureUnit": {
|
| 402 |
+
"id": 999,
|
| 403 |
+
"name": "cup", β structured unit name
|
| 404 |
+
"abbreviation": "cup"
|
| 405 |
+
},
|
| 406 |
+
"portionDescription": "1 cup"
|
| 407 |
+
}
|
| 408 |
+
"""
|
| 409 |
+
best_score = -1.0
|
| 410 |
+
best_gw: Optional[float] = None
|
| 411 |
+
size_units = {'small', 'medium', 'large', 'whole', 'piece'}
|
| 412 |
+
penalized_keywords = ['slice', 'sliced', 'chopped', 'diced', 'rings']
|
| 413 |
+
|
| 414 |
+
for portion in portions:
|
| 415 |
+
gw = portion.get("gramWeight")
|
| 416 |
+
amount = portion.get("amount") or 1.0
|
| 417 |
+
if not gw or gw <= 0 or not amount:
|
| 418 |
+
continue
|
| 419 |
+
|
| 420 |
+
# Gather candidate text fields for this portion
|
| 421 |
+
candidates = self._portion_text_candidates(portion)
|
| 422 |
+
|
| 423 |
+
for text in candidates:
|
| 424 |
+
if not text:
|
| 425 |
+
continue
|
| 426 |
+
usda_canonical = self._canonicalize(text)
|
| 427 |
+
|
| 428 |
+
# Exact match β done
|
| 429 |
+
if usda_canonical == canonical_unit:
|
| 430 |
+
score = 1.0
|
| 431 |
+
# Substring match β "medium onion" contains "medium"
|
| 432 |
+
elif canonical_unit in usda_canonical or usda_canonical in canonical_unit:
|
| 433 |
+
score = 0.9
|
| 434 |
+
else:
|
| 435 |
+
score = SequenceMatcher(None, canonical_unit, usda_canonical).ratio()
|
| 436 |
+
|
| 437 |
+
# Adjust portion ranking: favor whole-item/each portions when input unit is medium/whole
|
| 438 |
+
if canonical_unit in size_units:
|
| 439 |
+
if any(x in usda_canonical for x in penalized_keywords):
|
| 440 |
+
score -= 0.4
|
| 441 |
+
if any(x in usda_canonical for x in (canonical_unit, "whole", "each")):
|
| 442 |
+
score += 0.05
|
| 443 |
+
|
| 444 |
+
if score > best_score and score >= 0.78:
|
| 445 |
+
best_score = score
|
| 446 |
+
best_gw = gw / amount
|
| 447 |
+
|
| 448 |
+
return best_gw
|
| 449 |
+
|
| 450 |
+
def _match_measures(
|
| 451 |
+
self, canonical_unit: str, measures: list[dict]
|
| 452 |
+
) -> Optional[float]:
|
| 453 |
+
"""
|
| 454 |
+
Match against foodMeasures (also present on /food/{fdcId}).
|
| 455 |
+
|
| 456 |
+
foodMeasures entry shape:
|
| 457 |
+
{
|
| 458 |
+
"disseminationText": "1 cup",
|
| 459 |
+
"gramWeight": 186.0,
|
| 460 |
+
"id": 456,
|
| 461 |
+
"measureUnitAbbreviation": "cup",
|
| 462 |
+
"measureUnitName": "cup",
|
| 463 |
+
"rank": 1
|
| 464 |
+
}
|
| 465 |
+
"""
|
| 466 |
+
best_score = 0.0
|
| 467 |
+
best_gw: Optional[float] = None
|
| 468 |
+
|
| 469 |
+
for measure in measures:
|
| 470 |
+
gw = measure.get("gramWeight")
|
| 471 |
+
dissem = measure.get("disseminationText", "")
|
| 472 |
+
if not gw or gw <= 0 or not dissem:
|
| 473 |
+
continue
|
| 474 |
+
|
| 475 |
+
usda_qty, usda_unit_raw = self._parse_usda_dissem(dissem)
|
| 476 |
+
if not usda_qty or usda_qty <= 0:
|
| 477 |
+
continue
|
| 478 |
+
|
| 479 |
+
usda_canonical = self._canonicalize(usda_unit_raw)
|
| 480 |
+
|
| 481 |
+
if usda_canonical == canonical_unit:
|
| 482 |
+
return gw / usda_qty
|
| 483 |
+
|
| 484 |
+
if canonical_unit in usda_canonical or usda_canonical in canonical_unit:
|
| 485 |
+
score = 0.9
|
| 486 |
+
else:
|
| 487 |
+
score = SequenceMatcher(None, canonical_unit, usda_canonical).ratio()
|
| 488 |
+
|
| 489 |
+
if score > best_score and score >= 0.78:
|
| 490 |
+
best_score = score
|
| 491 |
+
best_gw = gw / usda_qty
|
| 492 |
+
|
| 493 |
+
return best_gw
|
| 494 |
+
|
| 495 |
+
# ==================================================================
|
| 496 |
+
# Parsing helpers
|
| 497 |
+
# ==================================================================
|
| 498 |
+
|
| 499 |
+
@staticmethod
|
| 500 |
+
def _portion_text_candidates(portion: dict) -> list[str]:
|
| 501 |
+
"""
|
| 502 |
+
Extract all text fields from a foodPortions entry that might
|
| 503 |
+
describe the unit β in priority order.
|
| 504 |
+
"""
|
| 505 |
+
candidates = []
|
| 506 |
+
|
| 507 |
+
# Structured unit name (most reliable)
|
| 508 |
+
mu = portion.get("measureUnit") or {}
|
| 509 |
+
if mu.get("name") and mu["name"].lower() != "undetermined":
|
| 510 |
+
candidates.append(mu["name"])
|
| 511 |
+
if mu.get("abbreviation") and mu["abbreviation"].lower() != "undetermined":
|
| 512 |
+
candidates.append(mu["abbreviation"])
|
| 513 |
+
|
| 514 |
+
# Free-text modifier: "1 cup", "medium", "1 NLEA serving"
|
| 515 |
+
modifier = portion.get("modifier", "")
|
| 516 |
+
if modifier:
|
| 517 |
+
# Strip leading quantity if present ("1 cup" β "cup")
|
| 518 |
+
clean = re.sub(r"^\d+[\./]?\d*\s*", "", modifier).strip()
|
| 519 |
+
candidates.append(clean)
|
| 520 |
+
candidates.append(modifier) # also try full string
|
| 521 |
+
|
| 522 |
+
# portionDescription as last resort
|
| 523 |
+
desc = portion.get("portionDescription", "")
|
| 524 |
+
if desc:
|
| 525 |
+
clean = re.sub(r"^\d+[\./]?\d*\s*", "", desc).strip()
|
| 526 |
+
candidates.append(clean)
|
| 527 |
+
|
| 528 |
+
return [c.strip().lower() for c in candidates if c.strip()]
|
| 529 |
+
|
| 530 |
+
@staticmethod
|
| 531 |
+
def _parse_usda_dissem(text: str) -> tuple[Optional[float], str]:
|
| 532 |
+
"""
|
| 533 |
+
Parse USDA disseminationText.
|
| 534 |
+
|
| 535 |
+
"1 cup" β (1.0, "cup")
|
| 536 |
+
"1/2 teaspoon" β (0.5, "teaspoon")
|
| 537 |
+
"3 cloves" β (3.0, "cloves")
|
| 538 |
+
"medium" β (1.0, "medium") β no leading number
|
| 539 |
+
"""
|
| 540 |
+
text = text.strip()
|
| 541 |
+
match = re.match(r"^(\d+(?:\.\d+)?|\d+/\d+)\s+(.*)", text)
|
| 542 |
+
if not match:
|
| 543 |
+
# No leading number β treat whole string as unit name, qty=1
|
| 544 |
+
return 1.0, text
|
| 545 |
+
|
| 546 |
+
qty_str, unit_str = match.group(1), match.group(2).strip()
|
| 547 |
+
if "/" in qty_str:
|
| 548 |
+
try:
|
| 549 |
+
num, den = qty_str.split("/", 1)
|
| 550 |
+
qty = float(num) / float(den)
|
| 551 |
+
except (ValueError, ZeroDivisionError):
|
| 552 |
+
return None, unit_str
|
| 553 |
+
else:
|
| 554 |
+
try:
|
| 555 |
+
qty = float(qty_str)
|
| 556 |
+
except ValueError:
|
| 557 |
+
return None, unit_str
|
| 558 |
+
|
| 559 |
+
return qty, unit_str
|
| 560 |
+
|
| 561 |
+
@staticmethod
|
| 562 |
+
def _parse_quantity(raw: str) -> Optional[float]:
|
| 563 |
+
"""
|
| 564 |
+
Parse a quantity string to float.
|
| 565 |
+
|
| 566 |
+
"1" β 1.0
|
| 567 |
+
"1.5" β 1.5
|
| 568 |
+
"1/2" β 0.5
|
| 569 |
+
"Β½" β 0.5
|
| 570 |
+
"2 cup" β 2.0 (leading number extracted)
|
| 571 |
+
"""
|
| 572 |
+
UNICODE_FRACTIONS = {
|
| 573 |
+
"Β½": "1/2", "β
": "1/3", "β
": "2/3",
|
| 574 |
+
"ΒΌ": "1/4", "ΒΎ": "3/4", "β
": "1/8",
|
| 575 |
+
}
|
| 576 |
+
for char, repl in UNICODE_FRACTIONS.items():
|
| 577 |
+
raw = raw.replace(char, repl)
|
| 578 |
+
|
| 579 |
+
match = re.match(r"^(\d+(?:\.\d+)?|\d+/\d+)", raw.strip())
|
| 580 |
+
if not match:
|
| 581 |
+
return None
|
| 582 |
+
|
| 583 |
+
qty_str = match.group(1)
|
| 584 |
+
if "/" in qty_str:
|
| 585 |
+
try:
|
| 586 |
+
num, den = qty_str.split("/", 1)
|
| 587 |
+
return float(num) / float(den)
|
| 588 |
+
except (ValueError, ZeroDivisionError):
|
| 589 |
+
return None
|
| 590 |
+
try:
|
| 591 |
+
return float(qty_str)
|
| 592 |
+
except ValueError:
|
| 593 |
+
return None
|
| 594 |
+
|
| 595 |
+
@staticmethod
|
| 596 |
+
def _canonicalize(unit: str) -> str:
|
| 597 |
+
"""
|
| 598 |
+
'Tbsp.' β 'tablespoon'
|
| 599 |
+
'CLOVES' β 'clove'
|
| 600 |
+
'1 cup' β 'cup' (leading quantity stripped before alias lookup)
|
| 601 |
+
'1 tsp' β 'teaspoon' (strip number, then alias tspβteaspoon)
|
| 602 |
+
'3 cloves'β 'clove' (strip number, strip trailing s)
|
| 603 |
+
'medium onion' β try alias, fall back to lowercased string
|
| 604 |
+
"""
|
| 605 |
+
cleaned = unit.strip().lower()
|
| 606 |
+
# Strip leading integer/fraction quantity ("1 ", "1/2 ", "0.25 ") so
|
| 607 |
+
# USDA modifier strings like "1 cup" or "3 cloves" resolve via the alias table.
|
| 608 |
+
stripped_qty = re.sub(r"^\d+(?:[./]\d+)?\s+", "", cleaned).strip()
|
| 609 |
+
# Try each form: number-stripped first (most specific), then with number
|
| 610 |
+
for candidate in (stripped_qty, cleaned):
|
| 611 |
+
if candidate in UNIT_ALIASES:
|
| 612 |
+
return UNIT_ALIASES[candidate]
|
| 613 |
+
without_suffix = candidate.rstrip(".").rstrip("s")
|
| 614 |
+
if without_suffix in UNIT_ALIASES:
|
| 615 |
+
return UNIT_ALIASES[without_suffix]
|
| 616 |
+
return stripped_qty or cleaned
|
| 617 |
+
|
| 618 |
+
@staticmethod
|
| 619 |
+
def _result(name: str, original: str, grams: float) -> dict:
|
| 620 |
+
return {
|
| 621 |
+
"name": name,
|
| 622 |
+
"original_quantity": original,
|
| 623 |
+
"quantity": round(grams, 4),
|
| 624 |
+
"unit": "g",
|
| 625 |
+
}
|
app/services/supabase.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import httpx
|
| 3 |
+
from .http_client import http_client
|
| 4 |
+
|
| 5 |
+
class SupabaseQuerier:
|
| 6 |
+
def __init__(self, url: str = None, key: str = None):
|
| 7 |
+
self.url = url or os.environ.get("SUPABASE_URL", "")
|
| 8 |
+
self.key = key or os.environ.get("SUPABASE_ANON_KEY", "")
|
| 9 |
+
|
| 10 |
+
async def get_product_by_slug(self, slug: str) -> dict | None:
|
| 11 |
+
if not self.url or not self.key:
|
| 12 |
+
print("[SupabaseQuerier] Warning: Supabase credentials missing.")
|
| 13 |
+
return None
|
| 14 |
+
|
| 15 |
+
base_url = self.url.rstrip("/")
|
| 16 |
+
query_url = f"{base_url}/rest/v1/inventory?slug=eq.{slug}&select=sku,slug,name,price_rupees,staging_dirs"
|
| 17 |
+
|
| 18 |
+
headers = {
|
| 19 |
+
"apikey": self.key,
|
| 20 |
+
"Authorization": f"Bearer {self.key}"
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
client = http_client if http_client is not None else httpx.AsyncClient()
|
| 24 |
+
close_client = http_client is None
|
| 25 |
+
try:
|
| 26 |
+
response = await client.get(query_url, headers=headers, timeout=5.0)
|
| 27 |
+
if response.status_code == 200:
|
| 28 |
+
data = response.json()
|
| 29 |
+
if isinstance(data, list) and len(data) > 0:
|
| 30 |
+
return data[0]
|
| 31 |
+
else:
|
| 32 |
+
print(f"[SupabaseQuerier] Query failed: {response.status_code} - {response.text}")
|
| 33 |
+
except Exception as e:
|
| 34 |
+
print(f"[SupabaseQuerier] Error querying Supabase: {e}")
|
| 35 |
+
finally:
|
| 36 |
+
if close_client:
|
| 37 |
+
await client.aclose()
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
async def get_order_history(self, user_id: str, days: int = 90) -> list:
|
| 41 |
+
if not self.url or not self.key:
|
| 42 |
+
print("[SupabaseQuerier] Warning: Supabase credentials missing.")
|
| 43 |
+
return []
|
| 44 |
+
|
| 45 |
+
from datetime import datetime, timedelta
|
| 46 |
+
cutoff_date = (datetime.utcnow() - timedelta(days=days)).isoformat()
|
| 47 |
+
|
| 48 |
+
base_url = self.url.rstrip("/")
|
| 49 |
+
query_url = f"{base_url}/rest/v1/user_carts?user_id=eq.{user_id}&status=eq.processed&created_at=gte.{cutoff_date}&select=items,created_at"
|
| 50 |
+
|
| 51 |
+
headers = {
|
| 52 |
+
"apikey": self.key,
|
| 53 |
+
"Authorization": f"Bearer {self.key}"
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
client = http_client if http_client is not None else httpx.AsyncClient()
|
| 57 |
+
close_client = http_client is None
|
| 58 |
+
try:
|
| 59 |
+
print(f"DEBUG: Query URL: {query_url}")
|
| 60 |
+
response = await client.get(query_url, headers=headers, timeout=5.0)
|
| 61 |
+
print(f"DEBUG: Status Code: {response.status_code}")
|
| 62 |
+
if response.status_code == 200:
|
| 63 |
+
print(f"DEBUG: Response length: {len(response.json())}")
|
| 64 |
+
return response.json()
|
| 65 |
+
else:
|
| 66 |
+
print(f"[SupabaseQuerier] Order history query failed: {response.status_code} - {response.text}")
|
| 67 |
+
except Exception as e:
|
| 68 |
+
print(f"[SupabaseQuerier] Error querying Supabase order history: {e}")
|
| 69 |
+
finally:
|
| 70 |
+
if close_client:
|
| 71 |
+
await client.aclose()
|
| 72 |
+
return []
|
app/utils/cart_state.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
from typing import Dict, Any, List
|
| 3 |
+
|
| 4 |
+
class InMemoryCartStateManager:
|
| 5 |
+
def __init__(self):
|
| 6 |
+
# Dictionary structure: { user_id: { sku: quantity } }
|
| 7 |
+
self._carts: Dict[str, Dict[str, int]] = {}
|
| 8 |
+
self._lock = threading.Lock()
|
| 9 |
+
|
| 10 |
+
def sync_from_client(self, user_id: str, current_cart: List[Dict[str, Any]]) -> None:
|
| 11 |
+
"""
|
| 12 |
+
Resets the server-side cart for a user to exactly match the authoritative
|
| 13 |
+
state sent by the Flutter client on every request.
|
| 14 |
+
|
| 15 |
+
Flutter's CartService is the single source of truth. This prevents the
|
| 16 |
+
server accumulating stale quantities when the cart is modified externally
|
| 17 |
+
(dashboard scanner, checkout, manual clear from outside the chatbot).
|
| 18 |
+
"""
|
| 19 |
+
with self._lock:
|
| 20 |
+
if not current_cart:
|
| 21 |
+
# Cart was cleared externally β wipe server state entirely
|
| 22 |
+
self._carts[user_id] = {}
|
| 23 |
+
else:
|
| 24 |
+
# Rebuild map directly from client payload
|
| 25 |
+
self._carts[user_id] = {
|
| 26 |
+
item["sku"]: int(item.get("quantity", 1))
|
| 27 |
+
for item in current_cart
|
| 28 |
+
if item.get("sku")
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
def add_item(self, user_id: str, sku: str, quantity: int = 1) -> bool:
|
| 32 |
+
"""Thread-safe write operation to add or increment a SKU in a user's cart."""
|
| 33 |
+
with self._lock:
|
| 34 |
+
if user_id not in self._carts:
|
| 35 |
+
self._carts[user_id] = {}
|
| 36 |
+
|
| 37 |
+
# Increment the quantity if it already exists, otherwise set it
|
| 38 |
+
current_qty = self._carts[user_id].get(sku, 0)
|
| 39 |
+
self._carts[user_id][sku] = current_qty + quantity
|
| 40 |
+
return True
|
| 41 |
+
|
| 42 |
+
def get_cart(self, user_id: str) -> Dict[str, int]:
|
| 43 |
+
"""Thread-safe read operation to fetch a user's full cart map."""
|
| 44 |
+
with self._lock:
|
| 45 |
+
# Return a copy to prevent external mutation outside the lock
|
| 46 |
+
return dict(self._carts.get(user_id, {}))
|
| 47 |
+
|
| 48 |
+
def clear_cart(self, user_id: str) -> None:
|
| 49 |
+
"""Thread-safe operation to wipe out a cart session."""
|
| 50 |
+
with self._lock:
|
| 51 |
+
if user_id in self._carts:
|
| 52 |
+
self._carts[user_id] = {}
|
| 53 |
+
|
| 54 |
+
def remove_item(self, user_id: str, sku: str, quantity: int = 1) -> bool:
|
| 55 |
+
"""Thread-safe write operation to remove or decrement a SKU in a user's cart."""
|
| 56 |
+
with self._lock:
|
| 57 |
+
if user_id not in self._carts or sku not in self._carts[user_id]:
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
current_qty = self._carts[user_id][sku]
|
| 61 |
+
if current_qty > quantity:
|
| 62 |
+
self._carts[user_id][sku] = current_qty - quantity
|
| 63 |
+
else:
|
| 64 |
+
del self._carts[user_id][sku]
|
| 65 |
+
return True
|
| 66 |
+
|
| 67 |
+
# Instantiate a single global singleton instance to be imported across modules
|
| 68 |
+
live_cart_memory = InMemoryCartStateManager()
|
inventory.json
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"currency": "INR",
|
| 3 |
+
"pricing_note": "Approximate catalog prices for demo inventory; staging images do not include normalized pack sizes.",
|
| 4 |
+
"items": [
|
| 5 |
+
{
|
| 6 |
+
"sku": "QLS-0001",
|
| 7 |
+
"slug": "5-star-magic-health-drink-bournvita",
|
| 8 |
+
"name": "Bournvita 5 Star Magic Health Drink",
|
| 9 |
+
"price_rupees": 249,
|
| 10 |
+
"staging_dirs": [
|
| 11 |
+
"5-star-magic-health-drink-bournvita"
|
| 12 |
+
]
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"sku": "QLS-0002",
|
| 16 |
+
"slug": "air-freshener-ambi-pur",
|
| 17 |
+
"name": "Ambi Pur Air Freshener",
|
| 18 |
+
"price_rupees": 299,
|
| 19 |
+
"staging_dirs": [
|
| 20 |
+
"air-freshener-ambi-pur"
|
| 21 |
+
]
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"sku": "QLS-0003",
|
| 25 |
+
"slug": "almond-chocolate-bar-snickers",
|
| 26 |
+
"name": "Snickers Almond Chocolate Bar",
|
| 27 |
+
"price_rupees": 50,
|
| 28 |
+
"staging_dirs": [
|
| 29 |
+
"almond-chocolate-bar-snickers"
|
| 30 |
+
]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"sku": "QLS-0004",
|
| 34 |
+
"slug": "american-style-cream-and-onion-chips-lays",
|
| 35 |
+
"name": "Lay's American Style Cream and Onion Chips",
|
| 36 |
+
"price_rupees": 20,
|
| 37 |
+
"staging_dirs": [
|
| 38 |
+
"american-style-cream-and-onion-chips-lays",
|
| 39 |
+
"american-style-cream-and-onion-chips-lays-2",
|
| 40 |
+
"american-style-cream-and-onion-chips-lays-3",
|
| 41 |
+
"american-style-cream-and-onion-chips-lays-4"
|
| 42 |
+
]
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"sku": "QLS-0005",
|
| 46 |
+
"slug": "car-air-freshener-jasmine-ambi-pur",
|
| 47 |
+
"name": "Ambi Pur Jasmine Car Air Freshener",
|
| 48 |
+
"price_rupees": 399,
|
| 49 |
+
"staging_dirs": [
|
| 50 |
+
"car-air-freshener-jasmine-ambi-pur"
|
| 51 |
+
]
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"sku": "QLS-0006",
|
| 55 |
+
"slug": "ceregrow-multigrain-cereal-nestle",
|
| 56 |
+
"name": "Nestle Ceregrow Multigrain Cereal",
|
| 57 |
+
"price_rupees": 365,
|
| 58 |
+
"staging_dirs": [
|
| 59 |
+
"ceregrow-multigrain-cereal-nestle",
|
| 60 |
+
"ceregrow-multigrain-cereal-nestle-2"
|
| 61 |
+
]
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"sku": "QLS-0007",
|
| 65 |
+
"slug": "cerelac-rice-nestle",
|
| 66 |
+
"name": "Nestle Cerelac Rice",
|
| 67 |
+
"price_rupees": 285,
|
| 68 |
+
"staging_dirs": [
|
| 69 |
+
"cerelac-rice-nestle"
|
| 70 |
+
]
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"sku": "QLS-0008",
|
| 74 |
+
"slug": "cerelac-wheat-apple-nestle",
|
| 75 |
+
"name": "Nestle Cerelac Wheat Apple",
|
| 76 |
+
"price_rupees": 325,
|
| 77 |
+
"staging_dirs": [
|
| 78 |
+
"cerelac-wheat-apple-nestle"
|
| 79 |
+
]
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"sku": "QLS-0009",
|
| 83 |
+
"slug": "cheez-puffs-cheetos",
|
| 84 |
+
"name": "Cheetos Cheez Puffs",
|
| 85 |
+
"price_rupees": 20,
|
| 86 |
+
"staging_dirs": [
|
| 87 |
+
"cheez-puffs-cheetos",
|
| 88 |
+
"cheez-puffs-cheetos-2"
|
| 89 |
+
]
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"sku": "QLS-0010",
|
| 93 |
+
"slug": "chicken-noodles-maggi",
|
| 94 |
+
"name": "Maggi Chicken Noodles",
|
| 95 |
+
"price_rupees": 15,
|
| 96 |
+
"staging_dirs": [
|
| 97 |
+
"chicken-noodles-maggi"
|
| 98 |
+
]
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"sku": "QLS-0011",
|
| 102 |
+
"slug": "chocolate-bar-mars",
|
| 103 |
+
"name": "Mars Chocolate Bar",
|
| 104 |
+
"price_rupees": 40,
|
| 105 |
+
"staging_dirs": [
|
| 106 |
+
"chocolate-bar-mars"
|
| 107 |
+
]
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"sku": "QLS-0012",
|
| 111 |
+
"slug": "chocolate-bar-snickers",
|
| 112 |
+
"name": "Snickers Chocolate Bar",
|
| 113 |
+
"price_rupees": 40,
|
| 114 |
+
"staging_dirs": [
|
| 115 |
+
"chocolate-bar-snickers"
|
| 116 |
+
]
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
"sku": "QLS-0013",
|
| 120 |
+
"slug": "chocolate-bar-softer-bar",
|
| 121 |
+
"name": "Softer Bar Chocolate Bar",
|
| 122 |
+
"price_rupees": 30,
|
| 123 |
+
"staging_dirs": [
|
| 124 |
+
"chocolate-bar-softer-bar"
|
| 125 |
+
]
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"sku": "QLS-0014",
|
| 129 |
+
"slug": "chocos-crunchy-bites-coco-caramel-kelloggs",
|
| 130 |
+
"name": "Kellogg's Chocos Crunchy Bites Coco Caramel",
|
| 131 |
+
"price_rupees": 35,
|
| 132 |
+
"staging_dirs": [
|
| 133 |
+
"chocos-crunchy-bites-coco-caramel-kelloggs"
|
| 134 |
+
]
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"sku": "QLS-0015",
|
| 138 |
+
"slug": "classic-malt-health-drink-horlicks",
|
| 139 |
+
"name": "Horlicks Classic Malt Health Drink",
|
| 140 |
+
"price_rupees": 249,
|
| 141 |
+
"staging_dirs": [
|
| 142 |
+
"classic-malt-health-drink-horlicks"
|
| 143 |
+
]
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"sku": "QLS-0016",
|
| 147 |
+
"slug": "corn-flakes-original-kelloggs",
|
| 148 |
+
"name": "Kellogg's Corn Flakes Original",
|
| 149 |
+
"price_rupees": 195,
|
| 150 |
+
"staging_dirs": [
|
| 151 |
+
"corn-flakes-original-kelloggs",
|
| 152 |
+
"corn-flakes-original-kelloggs-2"
|
| 153 |
+
]
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"sku": "QLS-0017",
|
| 157 |
+
"slug": "dairy-milk-chocolate-cadbury",
|
| 158 |
+
"name": "Cadbury Dairy Milk Chocolate",
|
| 159 |
+
"price_rupees": 40,
|
| 160 |
+
"staging_dirs": [
|
| 161 |
+
"dairy-milk-chocolate-cadbury"
|
| 162 |
+
]
|
| 163 |
+
},
|
| 164 |
+
{
|
| 165 |
+
"sku": "QLS-0018",
|
| 166 |
+
"slug": "dairy-milk-crispello-cadbury",
|
| 167 |
+
"name": "Cadbury Dairy Milk Crispello",
|
| 168 |
+
"price_rupees": 40,
|
| 169 |
+
"staging_dirs": [
|
| 170 |
+
"dairy-milk-crispello-cadbury"
|
| 171 |
+
]
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
"sku": "QLS-0019",
|
| 175 |
+
"slug": "dairy-milk-roast-almond-cadbury",
|
| 176 |
+
"name": "Cadbury Dairy Milk Roast Almond",
|
| 177 |
+
"price_rupees": 50,
|
| 178 |
+
"staging_dirs": [
|
| 179 |
+
"dairy-milk-roast-almond-cadbury"
|
| 180 |
+
]
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"sku": "QLS-0020",
|
| 184 |
+
"slug": "disinfectant-surface-cleaner-lizol",
|
| 185 |
+
"name": "Lizol Disinfectant Surface Cleaner",
|
| 186 |
+
"price_rupees": 220,
|
| 187 |
+
"staging_dirs": [
|
| 188 |
+
"disinfectant-surface-cleaner-lizol"
|
| 189 |
+
]
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"sku": "QLS-0021",
|
| 193 |
+
"slug": "flushmatic-citrus-toilet-cleaner-harpic",
|
| 194 |
+
"name": "Harpic Flushmatic Citrus Toilet Cleaner",
|
| 195 |
+
"price_rupees": 99,
|
| 196 |
+
"staging_dirs": [
|
| 197 |
+
"flushmatic-citrus-toilet-cleaner-harpic"
|
| 198 |
+
]
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"sku": "QLS-0022",
|
| 202 |
+
"slug": "fuse-chocolate-bar-cadbury",
|
| 203 |
+
"name": "Cadbury Fuse Chocolate Bar",
|
| 204 |
+
"price_rupees": 40,
|
| 205 |
+
"staging_dirs": [
|
| 206 |
+
"fuse-chocolate-bar-cadbury"
|
| 207 |
+
]
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"sku": "QLS-0023",
|
| 211 |
+
"slug": "ginger-garlic-paste-aachi",
|
| 212 |
+
"name": "Aachi Ginger Garlic Paste",
|
| 213 |
+
"price_rupees": 80,
|
| 214 |
+
"staging_dirs": [
|
| 215 |
+
"ginger-garlic-paste-aachi"
|
| 216 |
+
]
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"sku": "QLS-0024",
|
| 220 |
+
"slug": "gold-standardised-milk-amul",
|
| 221 |
+
"name": "Amul Gold Standardised Milk",
|
| 222 |
+
"price_rupees": 37,
|
| 223 |
+
"staging_dirs": [
|
| 224 |
+
"gold-standardised-milk-amul"
|
| 225 |
+
]
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"sku": "QLS-0025",
|
| 229 |
+
"slug": "groundnut-oil-idhayam-mantra",
|
| 230 |
+
"name": "Idhayam Mantra Groundnut Oil",
|
| 231 |
+
"price_rupees": 250,
|
| 232 |
+
"staging_dirs": [
|
| 233 |
+
"groundnut-oil-idhayam-mantra"
|
| 234 |
+
]
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"sku": "QLS-0026",
|
| 238 |
+
"slug": "health-drink-2x-strength-bournvita",
|
| 239 |
+
"name": "Bournvita 2x Strength Health Drink",
|
| 240 |
+
"price_rupees": 275,
|
| 241 |
+
"staging_dirs": [
|
| 242 |
+
"health-drink-2x-strength-bournvita"
|
| 243 |
+
]
|
| 244 |
+
},
|
| 245 |
+
{
|
| 246 |
+
"sku": "QLS-0027",
|
| 247 |
+
"slug": "health-drink-boost",
|
| 248 |
+
"name": "Boost Health Drink",
|
| 249 |
+
"price_rupees": 255,
|
| 250 |
+
"staging_dirs": [
|
| 251 |
+
"health-drink-boost",
|
| 252 |
+
"health-drink-boost-2",
|
| 253 |
+
"health-drink-boost-3"
|
| 254 |
+
]
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
"sku": "QLS-0028",
|
| 258 |
+
"slug": "hot-and-spicy-korean-kimchi-noodles-geki",
|
| 259 |
+
"name": "Geki Hot and Spicy Korean Kimchi Noodles",
|
| 260 |
+
"price_rupees": 50,
|
| 261 |
+
"staging_dirs": [
|
| 262 |
+
"hot-and-spicy-korean-kimchi-noodles-geki"
|
| 263 |
+
]
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
"sku": "QLS-0029",
|
| 267 |
+
"slug": "hot-and-sweet-tomato-chilli-sauce-maggi",
|
| 268 |
+
"name": "Maggi Hot and Sweet Tomato Chilli Sauce",
|
| 269 |
+
"price_rupees": 170,
|
| 270 |
+
"staging_dirs": [
|
| 271 |
+
"hot-and-sweet-tomato-chilli-sauce-maggi"
|
| 272 |
+
]
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
"sku": "QLS-0030",
|
| 276 |
+
"slug": "indias-magic-masala-chips-lays",
|
| 277 |
+
"name": "Lay's India's Magic Masala Chips",
|
| 278 |
+
"price_rupees": 20,
|
| 279 |
+
"staging_dirs": [
|
| 280 |
+
"indias-magic-masala-chips-lays",
|
| 281 |
+
"indias-magic-masala-chips-lays-2"
|
| 282 |
+
]
|
| 283 |
+
},
|
| 284 |
+
{
|
| 285 |
+
"sku": "QLS-0031",
|
| 286 |
+
"slug": "jasmine-mist-air-freshener-odonil",
|
| 287 |
+
"name": "Odonil Jasmine Mist Air Freshener",
|
| 288 |
+
"price_rupees": 99,
|
| 289 |
+
"staging_dirs": [
|
| 290 |
+
"jasmine-mist-air-freshener-odonil"
|
| 291 |
+
]
|
| 292 |
+
},
|
| 293 |
+
{
|
| 294 |
+
"sku": "QLS-0032",
|
| 295 |
+
"slug": "kitkat-chunky-white-nestle",
|
| 296 |
+
"name": "Nestle KitKat Chunky White",
|
| 297 |
+
"price_rupees": 70,
|
| 298 |
+
"staging_dirs": [
|
| 299 |
+
"kitkat-chunky-white-nestle"
|
| 300 |
+
]
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"sku": "QLS-0033",
|
| 304 |
+
"slug": "lactogen-pro-1-nestle",
|
| 305 |
+
"name": "Nestle Lactogen Pro 1",
|
| 306 |
+
"price_rupees": 420,
|
| 307 |
+
"staging_dirs": [
|
| 308 |
+
"lactogen-pro-1-nestle"
|
| 309 |
+
]
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
"sku": "QLS-0034",
|
| 313 |
+
"slug": "lactogen-pro-4-nestle",
|
| 314 |
+
"name": "Nestle Lactogen Pro 4",
|
| 315 |
+
"price_rupees": 440,
|
| 316 |
+
"staging_dirs": [
|
| 317 |
+
"lactogen-pro-4-nestle"
|
| 318 |
+
]
|
| 319 |
+
},
|
| 320 |
+
{
|
| 321 |
+
"sku": "QLS-0035",
|
| 322 |
+
"slug": "milkybar-white-chocolate-nestle",
|
| 323 |
+
"name": "Nestle Milkybar White Chocolate",
|
| 324 |
+
"price_rupees": 20,
|
| 325 |
+
"staging_dirs": [
|
| 326 |
+
"milkybar-white-chocolate-nestle"
|
| 327 |
+
]
|
| 328 |
+
},
|
| 329 |
+
{
|
| 330 |
+
"sku": "QLS-0036",
|
| 331 |
+
"slug": "multigrain-chocos-chhota-bheem-kelloggs",
|
| 332 |
+
"name": "Kellogg's Multigrain Chocos Chhota Bheem",
|
| 333 |
+
"price_rupees": 20,
|
| 334 |
+
"staging_dirs": [
|
| 335 |
+
"multigrain-chocos-chhota-bheem-kelloggs"
|
| 336 |
+
]
|
| 337 |
+
},
|
| 338 |
+
{
|
| 339 |
+
"sku": "QLS-0037",
|
| 340 |
+
"slug": "multigrain-chocos-kelloggs",
|
| 341 |
+
"name": "Kellogg's Multigrain Chocos",
|
| 342 |
+
"price_rupees": 210,
|
| 343 |
+
"staging_dirs": [
|
| 344 |
+
"multigrain-chocos-kelloggs"
|
| 345 |
+
]
|
| 346 |
+
},
|
| 347 |
+
{
|
| 348 |
+
"sku": "QLS-0038",
|
| 349 |
+
"slug": "oats-quaker",
|
| 350 |
+
"name": "Quaker Oats",
|
| 351 |
+
"price_rupees": 195,
|
| 352 |
+
"staging_dirs": [
|
| 353 |
+
"oats-quaker"
|
| 354 |
+
]
|
| 355 |
+
},
|
| 356 |
+
{
|
| 357 |
+
"sku": "QLS-0039",
|
| 358 |
+
"slug": "power-pocket-rose-fresh-blossom-godrej-aer",
|
| 359 |
+
"name": "Godrej Aer Power Pocket Rose Fresh Blossom",
|
| 360 |
+
"price_rupees": 65,
|
| 361 |
+
"staging_dirs": [
|
| 362 |
+
"power-pocket-rose-fresh-blossom-godrej-aer"
|
| 363 |
+
]
|
| 364 |
+
},
|
| 365 |
+
{
|
| 366 |
+
"sku": "QLS-0040",
|
| 367 |
+
"slug": "puffcorn-yummy-cheese-kurkure-playz",
|
| 368 |
+
"name": "Kurkure Playz Puffcorn Yummy Cheese",
|
| 369 |
+
"price_rupees": 20,
|
| 370 |
+
"staging_dirs": [
|
| 371 |
+
"puffcorn-yummy-cheese-kurkure-playz"
|
| 372 |
+
]
|
| 373 |
+
},
|
| 374 |
+
{
|
| 375 |
+
"sku": "QLS-0041",
|
| 376 |
+
"slug": "refined-sunflower-oil-gold-winner",
|
| 377 |
+
"name": "Gold Winner Refined Sunflower Oil",
|
| 378 |
+
"price_rupees": 180,
|
| 379 |
+
"staging_dirs": [
|
| 380 |
+
"refined-sunflower-oil-gold-winner"
|
| 381 |
+
]
|
| 382 |
+
},
|
| 383 |
+
{
|
| 384 |
+
"sku": "QLS-0042",
|
| 385 |
+
"slug": "rich-tomato-ketchup-maggi",
|
| 386 |
+
"name": "Maggi Rich Tomato Ketchup",
|
| 387 |
+
"price_rupees": 155,
|
| 388 |
+
"staging_dirs": [
|
| 389 |
+
"rich-tomato-ketchup-maggi"
|
| 390 |
+
]
|
| 391 |
+
},
|
| 392 |
+
{
|
| 393 |
+
"sku": "QLS-0043",
|
| 394 |
+
"slug": "rolled-oats-disaano",
|
| 395 |
+
"name": "Disaano Rolled Oats",
|
| 396 |
+
"price_rupees": 199,
|
| 397 |
+
"staging_dirs": [
|
| 398 |
+
"rolled-oats-disaano"
|
| 399 |
+
]
|
| 400 |
+
},
|
| 401 |
+
{
|
| 402 |
+
"sku": "QLS-0044",
|
| 403 |
+
"slug": "room-spray-odonil",
|
| 404 |
+
"name": "Odonil Room Spray",
|
| 405 |
+
"price_rupees": 179,
|
| 406 |
+
"staging_dirs": [
|
| 407 |
+
"room-spray-odonil"
|
| 408 |
+
]
|
| 409 |
+
},
|
| 410 |
+
{
|
| 411 |
+
"sku": "QLS-0045",
|
| 412 |
+
"slug": "shampoo-hair-colour-godrej-selfie",
|
| 413 |
+
"name": "Godrej Selfie Shampoo Hair Colour",
|
| 414 |
+
"price_rupees": 30,
|
| 415 |
+
"staging_dirs": [
|
| 416 |
+
"shampoo-hair-colour-godrej-selfie"
|
| 417 |
+
]
|
| 418 |
+
},
|
| 419 |
+
{
|
| 420 |
+
"sku": "QLS-0046",
|
| 421 |
+
"slug": "similac-classic-stage-1-abbott",
|
| 422 |
+
"name": "Abbott Similac Classic Stage 1",
|
| 423 |
+
"price_rupees": 430,
|
| 424 |
+
"staging_dirs": [
|
| 425 |
+
"similac-classic-stage-1-abbott"
|
| 426 |
+
]
|
| 427 |
+
},
|
| 428 |
+
{
|
| 429 |
+
"sku": "QLS-0047",
|
| 430 |
+
"slug": "spanish-tomato-tango-chips-lays",
|
| 431 |
+
"name": "Lay's Spanish Tomato Tango Chips",
|
| 432 |
+
"price_rupees": 20,
|
| 433 |
+
"staging_dirs": [
|
| 434 |
+
"spanish-tomato-tango-chips-lays",
|
| 435 |
+
"spanish-tomato-tango-chips-lays-2"
|
| 436 |
+
]
|
| 437 |
+
},
|
| 438 |
+
{
|
| 439 |
+
"sku": "QLS-0048",
|
| 440 |
+
"slug": "spicy-manchurian-cuppa-noodles-maggi",
|
| 441 |
+
"name": "Maggi Spicy Manchurian Cuppa Noodles",
|
| 442 |
+
"price_rupees": 50,
|
| 443 |
+
"staging_dirs": [
|
| 444 |
+
"spicy-manchurian-cuppa-noodles-maggi"
|
| 445 |
+
]
|
| 446 |
+
},
|
| 447 |
+
{
|
| 448 |
+
"sku": "QLS-0049",
|
| 449 |
+
"slug": "super-muesli-zero-added-sugar-yoga-bar",
|
| 450 |
+
"name": "Yoga Bar Super Muesli Zero Added Sugar",
|
| 451 |
+
"price_rupees": 349,
|
| 452 |
+
"staging_dirs": [
|
| 453 |
+
"super-muesli-zero-added-sugar-yoga-bar"
|
| 454 |
+
]
|
| 455 |
+
},
|
| 456 |
+
{
|
| 457 |
+
"sku": "QLS-0050",
|
| 458 |
+
"slug": "sweet-chilli-hot-sauce-veeba",
|
| 459 |
+
"name": "Veeba Sweet Chilli Hot Sauce",
|
| 460 |
+
"price_rupees": 149,
|
| 461 |
+
"staging_dirs": [
|
| 462 |
+
"sweet-chilli-hot-sauce-veeba"
|
| 463 |
+
]
|
| 464 |
+
},
|
| 465 |
+
{
|
| 466 |
+
"sku": "QLS-0051",
|
| 467 |
+
"slug": "sweet-onion-sauce-veeba",
|
| 468 |
+
"name": "Veeba Sweet Onion Sauce",
|
| 469 |
+
"price_rupees": 149,
|
| 470 |
+
"staging_dirs": [
|
| 471 |
+
"sweet-onion-sauce-veeba"
|
| 472 |
+
]
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
"sku": "QLS-0052",
|
| 476 |
+
"slug": "synthetic-vinegar-double-horse",
|
| 477 |
+
"name": "Double Horse Synthetic Vinegar",
|
| 478 |
+
"price_rupees": 55,
|
| 479 |
+
"staging_dirs": [
|
| 480 |
+
"synthetic-vinegar-double-horse"
|
| 481 |
+
]
|
| 482 |
+
},
|
| 483 |
+
{
|
| 484 |
+
"sku": "QLS-0053",
|
| 485 |
+
"slug": "taj-mahal-tea-brooke-bond",
|
| 486 |
+
"name": "Brooke Bond Taj Mahal Tea",
|
| 487 |
+
"price_rupees": 260,
|
| 488 |
+
"staging_dirs": [
|
| 489 |
+
"taj-mahal-tea-brooke-bond"
|
| 490 |
+
]
|
| 491 |
+
},
|
| 492 |
+
{
|
| 493 |
+
"sku": "QLS-0054",
|
| 494 |
+
"slug": "thermosteel-water-bottle-milton",
|
| 495 |
+
"name": "Milton Thermosteel Water Bottle",
|
| 496 |
+
"price_rupees": 899,
|
| 497 |
+
"staging_dirs": [
|
| 498 |
+
"thermosteel-water-bottle-milton"
|
| 499 |
+
]
|
| 500 |
+
},
|
| 501 |
+
{
|
| 502 |
+
"sku": "QLS-0055",
|
| 503 |
+
"slug": "tomato-sauce-happy",
|
| 504 |
+
"name": "Happy Tomato Sauce",
|
| 505 |
+
"price_rupees": 110,
|
| 506 |
+
"staging_dirs": [
|
| 507 |
+
"tomato-sauce-happy"
|
| 508 |
+
]
|
| 509 |
+
},
|
| 510 |
+
{
|
| 511 |
+
"sku": "QLS-0056",
|
| 512 |
+
"slug": "womens-plus-caramel-horlicks",
|
| 513 |
+
"name": "Horlicks Women's Plus Caramel",
|
| 514 |
+
"price_rupees": 320,
|
| 515 |
+
"staging_dirs": [
|
| 516 |
+
"womens-plus-caramel-horlicks",
|
| 517 |
+
"womens-plus-caramel-horlicks-2"
|
| 518 |
+
]
|
| 519 |
+
}
|
| 520 |
+
]
|
| 521 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn==0.30.1
|
| 3 |
+
python-multipart==0.0.9
|
| 4 |
+
onnxruntime==1.19.2
|
| 5 |
+
huggingface-hub==0.23.4
|
| 6 |
+
transformers==4.41.2
|
| 7 |
+
pillow==10.3.0
|
| 8 |
+
httpx==0.27.0
|
| 9 |
+
groq>=1.4.0
|
| 10 |
+
python-dotenv>=1.0.0
|
| 11 |
+
pydantic>=2.0.0
|
| 12 |
+
numpy>=1.24.0
|
run_local.sh
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Stop and remove the existing container if running
|
| 3 |
+
echo "[*] Stopping existing container..."
|
| 4 |
+
docker stop ai-shopping-assistance-server 2>/dev/null || true
|
| 5 |
+
docker rm ai-shopping-assistance-server 2>/dev/null || true
|
| 6 |
+
|
| 7 |
+
# Rebuild the image
|
| 8 |
+
echo "[*] Building Docker image..."
|
| 9 |
+
docker build -t ai-shopping-assistance-server .
|
| 10 |
+
|
| 11 |
+
# Run the container mapping port 7860 to 6082
|
| 12 |
+
echo "[*] Running container..."
|
| 13 |
+
docker run -d \
|
| 14 |
+
--name ai-shopping-assistance-server \
|
| 15 |
+
-p 127.0.0.1:6082:7860 \
|
| 16 |
+
-e GROQ_API_KEY="$GROQ_API_KEY" \
|
| 17 |
+
-e CHROMA_API_KEY="$CHROMA_API_KEY" \
|
| 18 |
+
-e SUPABASE_URL="$SUPABASE_URL" \
|
| 19 |
+
-e SUPABASE_ANON_KEY="$SUPABASE_ANON_KEY" \
|
| 20 |
+
-e GROQ_MODEL="$GROQ_MODEL" \
|
| 21 |
+
-e GROQ_FALLBACK_MODEL="$GROQ_FALLBACK_MODEL" \
|
| 22 |
+
-v /home/ubuntu/AIShoppingAssistance_Server/captured_images:/code/captured_images \
|
| 23 |
+
-v /home/ubuntu/.cache/huggingface:/root/.cache/huggingface \
|
| 24 |
+
--restart unless-stopped \
|
| 25 |
+
ai-shopping-assistance-server
|
| 26 |
+
|
| 27 |
+
echo "[*] Done! Server is running and accessible locally at http://127.0.0.1:6082"
|
test_agent.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from app.agents.missing_regulars_agent import MissingRegularsAgent
|
| 3 |
+
|
| 4 |
+
async def test():
|
| 5 |
+
print("Initializing Agent...")
|
| 6 |
+
agent = MissingRegularsAgent()
|
| 7 |
+
user_id = "d5777910-84ac-4ac9-84a0-0819ad960378" # Synthetic User A
|
| 8 |
+
print(f"Testing for user: {user_id}")
|
| 9 |
+
|
| 10 |
+
orders = await agent.supabase.get_order_history(user_id=user_id, days=90)
|
| 11 |
+
print(f"Fetched {len(orders)} orders for user.")
|
| 12 |
+
|
| 13 |
+
result = await agent.analyze_cart(user_id, current_cart=[])
|
| 14 |
+
|
| 15 |
+
print("\n=== RESULTS ===")
|
| 16 |
+
print(f"Response Text:\n{result.get('response_text')}\n")
|
| 17 |
+
print("Missing Items List:")
|
| 18 |
+
for item in result.get('missing_regulars', []):
|
| 19 |
+
print(f" - {item['name']} (Bought {item['frequency']} times, avg gap: {item['avg_gap_days']} days)")
|
| 20 |
+
|
| 21 |
+
if __name__ == "__main__":
|
| 22 |
+
asyncio.run(test())
|
tests/test_groq.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.groq_client import GroqClient
|
| 2 |
+
|
| 3 |
+
client = GroqClient()
|
| 4 |
+
|
| 5 |
+
print(client.extract_recipe_request("I want Arrabiata for 2 people"))
|
| 6 |
+
print(client.extract_recipe_request("Make Chicken Curry for 4 people"))
|
| 7 |
+
print(client.extract_recipe_request("Prepare Veg Biryani for 5 persons"))
|
tests/test_milk.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
from app.services.nutrition_service import NutritionService
|
| 8 |
+
|
| 9 |
+
async def main():
|
| 10 |
+
service = NutritionService()
|
| 11 |
+
await service.get_nutrition("Amul Gold Standardised Milk")
|
| 12 |
+
|
| 13 |
+
asyncio.run(main())
|
tests/test_mixed_veg.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import os
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
from app.services.quantity_normalizer_service import QuantityNormalizerService
|
| 13 |
+
|
| 14 |
+
async def main():
|
| 15 |
+
async with QuantityNormalizerService(
|
| 16 |
+
api_key=os.getenv("USDA_API_KEY")
|
| 17 |
+
) as q:
|
| 18 |
+
|
| 19 |
+
result = await q.normalize_ingredient({
|
| 20 |
+
"name": "Mixed Vegetables (Carrots, Peas, Cauliflower)",
|
| 21 |
+
"quantity": "1",
|
| 22 |
+
"unit": "cup"
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
print(result)
|
| 26 |
+
|
| 27 |
+
asyncio.run(main())
|
tests/test_nutrition.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from app.services.nutrition_service import NutritionService
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
async def main():
|
| 6 |
+
service = NutritionService()
|
| 7 |
+
|
| 8 |
+
foods = [
|
| 9 |
+
"rice",
|
| 10 |
+
"milk",
|
| 11 |
+
"onion",
|
| 12 |
+
"carrot",
|
| 13 |
+
"potato",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
for food in foods:
|
| 17 |
+
result = await service.get_nutrition(food)
|
| 18 |
+
|
| 19 |
+
print()
|
| 20 |
+
print("=" * 40)
|
| 21 |
+
print(food.upper())
|
| 22 |
+
print(result)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
asyncio.run(main())
|
tests/test_onion.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import os
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
from app.services.quantity_normalizer_service import QuantityNormalizerService
|
| 13 |
+
|
| 14 |
+
async def main():
|
| 15 |
+
async with QuantityNormalizerService(
|
| 16 |
+
api_key=os.getenv("USDA_API_KEY")
|
| 17 |
+
) as q:
|
| 18 |
+
|
| 19 |
+
result = await q.normalize_ingredient({
|
| 20 |
+
"name": "Onion",
|
| 21 |
+
"quantity": "1",
|
| 22 |
+
"unit": "medium"
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
print(result)
|
| 26 |
+
|
| 27 |
+
asyncio.run(main())
|
tests/test_parser.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.agents.recipe_parser import RecipeParser
|
| 2 |
+
|
| 3 |
+
parser = RecipeParser()
|
| 4 |
+
|
| 5 |
+
print(
|
| 6 |
+
parser.extract_servings(
|
| 7 |
+
"Prep time 20 mins\nServes 4\nCook time 30 mins"
|
| 8 |
+
)
|
| 9 |
+
)
|
tests/test_pipeline.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
from app.agents.tools.quantity_parser_tool import QuantityParserTool
|
| 4 |
+
from app.agents.shopping_assistant_agent import ShoppingAssistantAgent
|
| 5 |
+
|
| 6 |
+
async def run_architecture_suite():
|
| 7 |
+
print("π STARTING INTEGRATION REGRESSION TESTING ENGINE...")
|
| 8 |
+
print("--------------------------------------------------")
|
| 9 |
+
|
| 10 |
+
parser = QuantityParserTool()
|
| 11 |
+
agent = ShoppingAssistantAgent()
|
| 12 |
+
|
| 13 |
+
# --- TEST SUITE 1: QUANTITY PARSING INTEGRITY ---
|
| 14 |
+
parsing_cases = [
|
| 15 |
+
{"input": "to taste pinch Salt", "expected_name": "Salt", "expected_unit": "pinch"},
|
| 16 |
+
{"input": "1 inch piece Ginger", "expected_name": "Ginger", "expected_unit": "inch piece"},
|
| 17 |
+
{"input": "3 cloves Garlic", "expected_name": "Garlic", "expected_unit": "cloves"},
|
| 18 |
+
{"input": "2 cups Basmati Rice", "expected_name": "Basmati Rice", "expected_unit": "cups"}
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
parsing_failures = 0
|
| 22 |
+
for case in parsing_cases:
|
| 23 |
+
res = parser.execute(case["input"])
|
| 24 |
+
# Check if structural duplication occurred ("cups cups") or leakages stayed in name
|
| 25 |
+
if case["expected_name"].lower() not in res.get("name", "").lower() or "cups cups" in res.get("raw_input", ""):
|
| 26 |
+
print(f"β REGRESSION DETECTED in parsing rule for: '{case['input']}' -> Got: {res}")
|
| 27 |
+
parsing_failures += 1
|
| 28 |
+
|
| 29 |
+
if parsing_failures == 0:
|
| 30 |
+
print("β
STAGE 1: Parser unit isolation tests passed perfectly.")
|
| 31 |
+
|
| 32 |
+
# --- TEST SUITE 2: INVENTORY HYDRATION CROSS-CONTAMINATION ---
|
| 33 |
+
print("\nπ΅οΈ TESTING SEMANTIC INVENTORY MATCH SAFETY...")
|
| 34 |
+
# Simulate an entry targeting your dangerous hallucination items (Harpic, Cerelac)
|
| 35 |
+
test_cart_slugs = ["carrots"]
|
| 36 |
+
|
| 37 |
+
# --- Test Case for Payload Injection Attempt ---
|
| 38 |
+
print("\nπ§ͺ TESTING PAYLOAD INJECTION RESISTANCE...")
|
| 39 |
+
injection_query = "biryani with harpic"
|
| 40 |
+
try:
|
| 41 |
+
injection_payload = await agent.process_recipe_workflow(
|
| 42 |
+
current_cart_slugs=test_cart_slugs,
|
| 43 |
+
dish_query=injection_query,
|
| 44 |
+
servings=4
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
injection_contamination_detected = False
|
| 48 |
+
for item in injection_payload.get("missing_ingredients", []):
|
| 49 |
+
name = item.get("name", "").lower()
|
| 50 |
+
if any(toxic in name for toxic in ["harpic", "cleaner", "lizol", "cerelac", "toilet"]):
|
| 51 |
+
print(f"β PAYLOAD INJECTION REGRESSION DETECTED: Agent suggested toxic item from injected query -> '{item.get('name')}'")
|
| 52 |
+
injection_contamination_detected = True
|
| 53 |
+
|
| 54 |
+
if not injection_contamination_detected:
|
| 55 |
+
print(f"β
STAGE 2.1: Agent resisted payload injection for query '{injection_query}'.")
|
| 56 |
+
|
| 57 |
+
except Exception as e:
|
| 58 |
+
print(f"β PAYLOAD INJECTION TEST FAULT CRASH: {e}")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# We run the workflow logic block natively
|
| 62 |
+
try:
|
| 63 |
+
payload = await agent.process_recipe_workflow(
|
| 64 |
+
current_cart_slugs=test_cart_slugs,
|
| 65 |
+
dish_query="Veg Biryani",
|
| 66 |
+
servings=4
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
missing = payload.get("missing_ingredients", [])
|
| 70 |
+
contamination_detected = False
|
| 71 |
+
|
| 72 |
+
for item in missing:
|
| 73 |
+
name = item.get("name", "").lower()
|
| 74 |
+
# Catch toxic cross-contamination links instantly
|
| 75 |
+
if any(toxic in name for toxic in ["harpic", "cleaner", "lizol", "cerelac", "toilet"]):
|
| 76 |
+
print(f"β TOXIC HALLUCINATION REGRESSION DETECTED: Recipe linked an ingredient to -> '{item.get('name')}'")
|
| 77 |
+
contamination_detected = True
|
| 78 |
+
|
| 79 |
+
if not contamination_detected:
|
| 80 |
+
print("β
STAGE 2: Safety guardrails holding. Zero toxic cross-contamination items found.")
|
| 81 |
+
|
| 82 |
+
except Exception as e:
|
| 83 |
+
print(f"β PIPELINE EXECUTION FAULT CRASH: {e}")
|
| 84 |
+
|
| 85 |
+
print("\n--------------------------------------------------")
|
| 86 |
+
print("π REGRESSION SUITE EXECUTION CYCLE COMPLETE.")
|
| 87 |
+
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
asyncio.run(run_architecture_suite())
|
tests/test_quantity.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.quantity_estimator import QuantityEstimator
|
| 2 |
+
|
| 3 |
+
q = QuantityEstimator()
|
| 4 |
+
|
| 5 |
+
print(
|
| 6 |
+
q.parse_ingredient(
|
| 7 |
+
"β’ 2 cups (400 grams) aged basmati rice"
|
| 8 |
+
)
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
print(
|
| 12 |
+
q.parse_ingredient(
|
| 13 |
+
"β’ ΒΎ cup carrots"
|
| 14 |
+
)
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
print(
|
| 18 |
+
q.parse_ingredient(
|
| 19 |
+
"β’ 1 cup yogurt"
|
| 20 |
+
)
|
| 21 |
+
)
|
| 22 |
+
print(q.parse_ingredient("4 green cardamom"))
|
| 23 |
+
print(q.parse_ingredient("1 bay leaf"))
|
| 24 |
+
print(q.parse_ingredient("2 green chili"))
|
tests/test_rice.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import os
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
print("USDA_API_KEY =", os.getenv("USDA_API_KEY"))
|
| 13 |
+
|
| 14 |
+
from app.services.quantity_normalizer_service import QuantityNormalizerService
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def main():
|
| 18 |
+
async with QuantityNormalizerService(
|
| 19 |
+
api_key=os.getenv("USDA_API_KEY")
|
| 20 |
+
) as q:
|
| 21 |
+
|
| 22 |
+
result = await q.normalize_ingredient({
|
| 23 |
+
"name": "Basmati Rice",
|
| 24 |
+
"quantity": "1",
|
| 25 |
+
"unit": "cup"
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
print(result)
|
| 29 |
+
|
| 30 |
+
asyncio.run(main())
|
tests/test_scaling.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.services.quantity_estimator import QuantityEstimator
|
| 2 |
+
|
| 3 |
+
q = QuantityEstimator()
|
| 4 |
+
|
| 5 |
+
print(q.scale_quantity("400 grams", 2))
|
| 6 |
+
print(q.scale_quantity("2 tablespoons", 3))
|
| 7 |
+
print(q.scale_quantity("Β½ teaspoon", 2))
|
tests/verify_usda_measures.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
USDA foodMeasures Verification Script
|
| 3 |
+
Run: python3 verify_usda_measures.py YOUR_API_KEY
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
import json
|
| 8 |
+
import urllib.request
|
| 9 |
+
import urllib.parse
|
| 10 |
+
|
| 11 |
+
API_KEY = sys.argv[1] if len(sys.argv) > 1 else "DEMO_KEY"
|
| 12 |
+
BASE_URL = "https://api.nal.usda.gov/fdc/v1/foods/search"
|
| 13 |
+
|
| 14 |
+
INGREDIENTS = [
|
| 15 |
+
("rice", "Basmati rice", ["cup", "tablespoon"]),
|
| 16 |
+
("onion", "Onion", ["medium", "small", "cup", "tablespoon"]),
|
| 17 |
+
("garlic", "Garlic", ["clove", "cup"]),
|
| 18 |
+
("oil", "Oil, vegetable", ["tablespoon", "cup", "teaspoon"]),
|
| 19 |
+
("tomato", "Tomatoes, raw", ["medium", "cup", "slice"]),
|
| 20 |
+
("potato", "Potato, raw", ["medium", "large", "cup"]),
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
DATA_TYPES = "SR Legacy,Foundation"
|
| 24 |
+
|
| 25 |
+
GREEN = "\033[92m"
|
| 26 |
+
RED = "\033[91m"
|
| 27 |
+
YELLOW = "\033[93m"
|
| 28 |
+
BOLD = "\033[1m"
|
| 29 |
+
RESET = "\033[0m"
|
| 30 |
+
|
| 31 |
+
results_summary = []
|
| 32 |
+
|
| 33 |
+
def fetch(query):
|
| 34 |
+
params = urllib.parse.urlencode({
|
| 35 |
+
"query": query,
|
| 36 |
+
"api_key": API_KEY,
|
| 37 |
+
"dataType": DATA_TYPES,
|
| 38 |
+
"pageSize": 1,
|
| 39 |
+
})
|
| 40 |
+
url = f"{BASE_URL}?{params}"
|
| 41 |
+
with urllib.request.urlopen(url, timeout=10) as r:
|
| 42 |
+
return json.loads(r.read())
|
| 43 |
+
|
| 44 |
+
print(f"\n{BOLD}USDA foodMeasures Verification{RESET}")
|
| 45 |
+
print(f"API key: {API_KEY[:8]}{'*' * (len(API_KEY)-8) if len(API_KEY) > 8 else ''}")
|
| 46 |
+
print(f"dataType filter: {DATA_TYPES}")
|
| 47 |
+
print("=" * 70)
|
| 48 |
+
|
| 49 |
+
for query, label, wanted_units in INGREDIENTS:
|
| 50 |
+
print(f"\n{BOLD}{label}{RESET} (query: '{query}')")
|
| 51 |
+
try:
|
| 52 |
+
data = fetch(query)
|
| 53 |
+
foods = data.get("foods", [])
|
| 54 |
+
|
| 55 |
+
if not foods:
|
| 56 |
+
print(f" {RED}β NO RESULTS returned by USDA{RESET}")
|
| 57 |
+
results_summary.append((label, False, "no results"))
|
| 58 |
+
continue
|
| 59 |
+
|
| 60 |
+
food = foods[0]
|
| 61 |
+
desc = food.get("description", "?")
|
| 62 |
+
dtype = food.get("dataType", "?")
|
| 63 |
+
measures = food.get("foodMeasures", [])
|
| 64 |
+
|
| 65 |
+
print(f" matched: {desc} [{dtype}]")
|
| 66 |
+
|
| 67 |
+
if not measures:
|
| 68 |
+
print(f" {RED}β foodMeasures MISSING β design assumption FAILS for this ingredient{RESET}")
|
| 69 |
+
results_summary.append((label, False, "foodMeasures absent"))
|
| 70 |
+
continue
|
| 71 |
+
|
| 72 |
+
print(f" {GREEN}β foodMeasures present ({len(measures)} entries){RESET}")
|
| 73 |
+
|
| 74 |
+
# Show all measures
|
| 75 |
+
found_units = set()
|
| 76 |
+
for m in measures:
|
| 77 |
+
text = m.get("disseminationText", "")
|
| 78 |
+
gw = m.get("gramWeight")
|
| 79 |
+
unit_word = text.split()[-1].lower().rstrip("s") if text else ""
|
| 80 |
+
found_units.add(unit_word)
|
| 81 |
+
print(f" {text:25s} β {gw} g")
|
| 82 |
+
|
| 83 |
+
# Check which wanted units are covered
|
| 84 |
+
print(f" wanted units: {wanted_units}")
|
| 85 |
+
hits = []
|
| 86 |
+
misses = []
|
| 87 |
+
for u in wanted_units:
|
| 88 |
+
u_singular = u.rstrip("s")
|
| 89 |
+
if any(u_singular in m.get("disseminationText","").lower() for m in measures):
|
| 90 |
+
hits.append(u)
|
| 91 |
+
else:
|
| 92 |
+
misses.append(u)
|
| 93 |
+
|
| 94 |
+
if hits:
|
| 95 |
+
print(f" {GREEN}covered: {hits}{RESET}")
|
| 96 |
+
if misses:
|
| 97 |
+
print(f" {YELLOW}not covered: {misses} (SI fallback or skip){RESET}")
|
| 98 |
+
|
| 99 |
+
all_ok = len(misses) == 0
|
| 100 |
+
results_summary.append((label, True, hits, misses))
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
print(f" {RED}ERROR: {e}{RESET}")
|
| 104 |
+
results_summary.append((label, False, str(e)))
|
| 105 |
+
|
| 106 |
+
# ββ Summary ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 107 |
+
print(f"\n{'=' * 70}")
|
| 108 |
+
print(f"{BOLD}SUMMARY{RESET}")
|
| 109 |
+
print(f"{'=' * 70}")
|
| 110 |
+
|
| 111 |
+
design_safe = True
|
| 112 |
+
for row in results_summary:
|
| 113 |
+
name = row[0]
|
| 114 |
+
ok = row[1]
|
| 115 |
+
if ok:
|
| 116 |
+
hits, misses = row[2], row[3]
|
| 117 |
+
status = f"{GREEN}β foodMeasures present{RESET}"
|
| 118 |
+
if misses:
|
| 119 |
+
status += f" {YELLOW}(missing: {misses}){RESET}"
|
| 120 |
+
print(f" {status} {name}")
|
| 121 |
+
else:
|
| 122 |
+
print(f" {status} {name}")
|
| 123 |
+
else:
|
| 124 |
+
design_safe = False
|
| 125 |
+
reason = row[2]
|
| 126 |
+
print(f" {RED}β FAILED ({reason}) {name}{RESET}")
|
| 127 |
+
|
| 128 |
+
print()
|
| 129 |
+
if design_safe:
|
| 130 |
+
print(f"{GREEN}{BOLD}β DESIGN ASSUMPTION HOLDS.{RESET}")
|
| 131 |
+
print(" USDA foodMeasures covers your core ingredients.")
|
| 132 |
+
print(" The QuantityNormalizerService approach is safe to build.")
|
| 133 |
+
else:
|
| 134 |
+
print(f"{RED}{BOLD}β DESIGN ASSUMPTION FAILS for one or more ingredients.{RESET}")
|
| 135 |
+
print(" Check the details above. You may need a fallback strategy.")
|
| 136 |
+
print()
|