Gowthamr23 commited on
Commit
bab4aa8
Β·
verified Β·
1 Parent(s): 27d491b

Upload folder using huggingface_hub

Browse files
.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: Fast Api Cloud
3
- emoji: πŸ“Š
4
- colorFrom: gray
5
- colorTo: indigo
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()