1+ import json
12import logging
23import os
34import re
@@ -117,31 +118,95 @@ def ai_complete(system: str, user: str, tools: list | None = None, max_retries:
117118 "Tools" , "First Aid" , "Safety" , "Camera" , "Climbing" ,
118119]
119120
121+ SUBCATEGORIES = {
122+ "Clothing" : [
123+ "Base Layers" , "Mid Layers" , "Insulation" , "Rain Gear" ,
124+ "Wind Gear" , "Socks" , "Underwear" , "Headwear" , "Gloves" ,
125+ "Pants & Shorts" , "Shirts & Tops" ,
126+ ],
127+ "Cookware" : [
128+ "Stoves" , "Fuel" , "Pots & Pans" , "Utensils" ,
129+ "Drinkware" , "Cleaning" , "Coffee/Tea" ,
130+ ],
131+ "Sleep System" : [
132+ "Sleeping Bags" , "Quilts" , "Sleeping Pads" , "Pillows" , "Liners" , "Bivys" ,
133+ ],
134+ "Electronics" : [
135+ "Power & Cables" , "Lighting" , "Navigation/Comm" , "Batteries" ,
136+ "Solar" , "Wearables" , "Audio" ,
137+ ],
138+ "Pack" : [
139+ "Main Pack" , "Daypacks" , "Protection" , "Organization" , "Add-ons" ,
140+ ],
141+ "Shelter" : [
142+ "Tents" , "Hammocks" , "Tarps" , "Hardware" , "Structure" ,
143+ ],
144+ "Toiletries" : [
145+ "Hygiene" , "Bathroom" , "Sun & Bug" , "Personal Care" ,
146+ ],
147+ "Water System" : [
148+ "Filtration" , "Purification" , "Bottles" , "Hydration" , "Storage" ,
149+ ],
150+ "Food" : [
151+ "Meals" , "Snacks" , "Beverages" , "Storage" , "Hanging" ,
152+ ],
153+ "Footware" : [
154+ "Primary" , "Camp Shoes" , "Gaiters" , "Traction" ,
155+ ],
156+ "Tools" : [
157+ "Knives" , "Repair" , "Trekking Poles" , "Processing" ,
158+ ],
159+ "First Aid" : [
160+ "Bandages" , "Medication" , "Blister Care" , "Ointments" ,
161+ ],
162+ "Safety" : [
163+ "Survival" , "Fire" , "Protection" ,
164+ ],
165+ "Camera" : [
166+ "Body & Lens" , "Support" , "Media" , "Power" ,
167+ ],
168+ "Climbing" : [
169+ "Personal" , "Hardware" , "Protection" , "Soft Goods" ,
170+ ],
171+ }
172+
173+ _subcategory_block = json .dumps (SUBCATEGORIES , indent = 2 )
174+
120175SYSTEM_PROMPT = (
121176 "You are a backpacking and outdoor gear product database. You have expert knowledge "
122177 "of outdoor gear brands, product lines, and specifications. When given a brand and product name "
123178 "(which may be misspelled, abbreviated, or include variant info in the name), you research and "
124179 "return the canonical product information.\n \n "
125- "You have access to web search. Use it to find the manufacturer's official product page. "
126- 'Prefer searching the manufacturer\' s site directly (e.g. "site:nemoequipment.com Tensor Insulated"). '
127- "From the product page, extract:\n "
128- "- The official product URL\n "
180+ "You have access to web search. Use it to find product pages from both the manufacturer's website "
181+ "and major retail sites (REI, Amazon, etc.), since many manufacturers do not sell directly. "
182+ "Try searching the manufacturer's site first "
183+ '(e.g. "site:nemoequipment.com Tensor Insulated"), then also search retail sites '
184+ '(e.g. "Nemo Tensor Insulated site:rei.com" or the product name on Amazon). '
185+ "From the best available product page(s), extract:\n "
186+ "- A product URL (prefer the manufacturer's page if available, otherwise use a retail page)\n "
129187 "- A product image URL (the main product photo, not a lifestyle/hero image)\n "
130- "- The manufacturer's listed weight and any other specs (R-value, volume, packed size, temperature rating, etc.)\n \n "
188+ "- The listed weight and any other specs (R-value, volume, packed size, temperature rating, etc.)\n \n "
131189 "IMPORTANT: A variant is a meaningful product option like size (S/M/L/Regular/Long), color, gender, "
132190 "or volume capacity. Weight measurements (e.g. \" 690g\" , \" 14oz\" , \" 2lb\" ), dimensions, or other specs "
133191 "that appear in the variant field are NOT real variants. If the provided variant is just a weight or "
134192 "spec measurement, set variant_name to null and incorporate that data into weight_grams or "
135193 "additional_specs instead.\n \n "
136194 "If the input is not a real, identifiable outdoor/backpacking product (e.g. \" small bag\" , \" misc item\" , "
137195 "random text), mark it as invalid.\n \n "
138- f"When assigning a category, you MUST use one of these exact values: { ', ' .join (CATEGORIES )} ."
196+ f"When assigning a category, you MUST use one of these exact values: { ', ' .join (CATEGORIES )} .\n \n "
197+ "After assigning a category, also assign a subcategory. The valid subcategories for each category are:\n "
198+ f"{ _subcategory_block } \n "
199+ "You MUST pick a subcategory from the list for the chosen category. If the product does not fit any "
200+ "subcategory, or the category is \" Miscellaneous\" , set subcategory to null.\n \n "
201+ "FOOD ITEMS: If the category is \" Food\" , look up the calories per serving (kcal) from the product "
202+ "page, nutrition label, or retailer listing. Report this value as kcal. For non-food items, set kcal "
203+ "to null."
139204)
140205
141206WEB_SEARCH_TOOL = {
142207 "type" : "web_search_20250305" ,
143208 "name" : "web_search" ,
144- "max_uses" : 3 ,
209+ "max_uses" : 5 ,
145210}
146211
147212TOOL_SCHEMA = {
@@ -172,11 +237,14 @@ def ai_complete(system: str, user: str, tools: list | None = None, max_retries:
172237 },
173238 "product_url" : {
174239 "type" : ["string" , "null" ],
175- "description" : "Official manufacturer product page URL. Null if unknown." ,
240+ "description" : (
241+ "Product page URL (prefer manufacturer's page; use a retail page "
242+ "if manufacturer doesn't sell direct). Null if unknown."
243+ ),
176244 },
177245 "image_url" : {
178246 "type" : ["string" , "null" ],
179- "description" : "URL of the main product photo from the manufacturer's site . Null if not found." ,
247+ "description" : "URL of the main product photo. Null if not found." ,
180248 },
181249 "description" : {
182250 "type" : ["string" , "null" ],
@@ -187,6 +255,20 @@ def ai_complete(system: str, user: str, tools: list | None = None, max_retries:
187255 "enum" : CATEGORIES + [None ],
188256 "description" : "Gear category. Must be one of the predefined values." ,
189257 },
258+ "subcategory" : {
259+ "type" : ["string" , "null" ],
260+ "description" : (
261+ "Subcategory within the assigned category. Must be one of the valid "
262+ "subcategories for the chosen category, or null."
263+ ),
264+ },
265+ "kcal" : {
266+ "type" : ["integer" , "null" ],
267+ "description" : (
268+ "Calories per serving for Food category items. Null for non-food "
269+ "items or if unknown."
270+ ),
271+ },
190272 "additional_specs" : {
191273 "type" : ["object" , "null" ],
192274 "description" : (
@@ -489,7 +571,9 @@ def enrich_product(self, brand_id: int, product_id: int, product_variant_id: int
489571 image_url = result .get ("image_url" ),
490572 description = result .get ("description" ),
491573 category_suggestion = result .get ("category" ),
574+ subcategory = result .get ("subcategory" ),
492575 additional_specs = result .get ("additional_specs" ),
576+ kcal = result .get ("kcal" ),
493577 brand_id = brand .id ,
494578 product_id = product .id ,
495579 product_variant_id = variant .id if variant else None ,
0 commit comments