Files
replypier/backend/app/tools/shopify_toolkit.py
T

1254 lines
54 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from typing import Optional, Dict, Any, List
from agno.tools import Toolkit
from app.core.logger import get_logger
from app.services.shopify import ShopifyService
from app.services.shopify_urls import shopify_admin_url
from app.database import SessionLocal
from app.models.shopify import ShopifyShop
from app.repositories.shopify_shop_repository import ShopifyShopRepository
from app.repositories.agent_shopify_config_repository import AgentShopifyConfigRepository
from uuid import UUID
import json
import traceback
import requests
from app.core.config import settings
from app.core.redis import get_redis
from app.tools.shopify_order_access import (
MAX_ORDER_RESULTS,
ORDER_NOT_FOUND,
build_order_search,
order_belongs_to,
order_lookup_problem,
order_number_matches,
)
logger = get_logger(__name__)
# A slow store must not hold the chat turn open.
ORDER_SEARCH_TIMEOUT_SECONDS = 20
ORDER_SEARCH_GRAPHQL = """
query SearchOrders($query: String, $first: Int!) {
orders(first: $first, query: $query, sortKey: CREATED_AT, reverse: true) {
edges {
node {
id
name
email
phone
createdAt
displayFinancialStatus
displayFulfillmentStatus
subtotalLineItemsQuantity
currentSubtotalPriceSet {
shopMoney {
amount
currencyCode
}
}
currentTotalPriceSet {
shopMoney {
amount
currencyCode
}
}
originalTotalPriceSet {
shopMoney {
amount
currencyCode
}
}
customer {
id
firstName
lastName
email
}
lineItems(first: 5) {
edges {
node {
title
quantity
originalUnitPriceSet {
shopMoney {
amount
currencyCode
}
}
variant {
id
title
image {
url
}
}
}
}
}
shippingAddress {
address1
address2
city
provinceCode
zip
country
}
fulfillments {
trackingInfo {
company
number
url
}
status
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
def create_minimal_product(product: dict) -> dict:
"""
Create minimal product object for LLM (remove unnecessary fields to reduce tokens).
Only includes fields needed for LLM reasoning.
"""
return {
"id": product.get("id"),
"title": product.get("title"),
"price": product.get("price"),
"price_max": product.get("price_max"),
"currency": product.get("currency"),
"vendor": product.get("vendor"),
"product_type": product.get("product_type"),
"total_inventory": product.get("total_inventory"),
"tags": product.get("tags", [])
}
class ShopifyTools(Toolkit):
def __init__(self, agent_id: str, org_id: str, session_id: str):
super().__init__(name="shopify_tools")
self.agent_id = agent_id
self.org_id = org_id
self.session_id = session_id
# True once a product tool has fetched+cached products during this turn.
# Lets the backend attach products deterministically (via product_cache_key)
# even if the LLM forgets to echo the key in its structured shopify_output.
# Per-request instance, so this cannot leak across turns.
self.has_cached_products: bool = False
# Register the functions
self.register(self.list_products)
self.register(self.get_product)
self.register(self.search_products)
self.register(self.search_orders)
self.register(self.get_order_status)
self.register(self.recommend_products)
@property
def product_cache_key(self) -> str:
"""Redis key holding this session's most recent product result.
Org-scoped for multi-tenant isolation. Single source of truth: the
backend re-hydrates products from this exact key, so every producer must
build it here rather than repeating the format.
"""
return f"{self.org_id}:shopify_products:{self.session_id}"
def _get_shop_for_agent(self) -> Optional[ShopifyShop]:
"""
Helper method to get the Shopify shop associated with the agent.
Returns:
ShopifyShop: The shop associated with the agent, or None if not found or not enabled.
"""
try:
logger.debug(f"Getting shop for agent {self.agent_id}")
with SessionLocal() as db:
# Get the agent's Shopify configuration
agent_shopify_config_repo = AgentShopifyConfigRepository(db)
shopify_config = agent_shopify_config_repo.get_agent_shopify_config(str(self.agent_id))
if not shopify_config or not shopify_config.enabled or not shopify_config.shop_id:
logger.warning("Shopify integration is not enabled for this agent")
return None
# Get the shop
shopify_repo = ShopifyShopRepository(db)
shop = shopify_repo.get_shop(shopify_config.shop_id)
if not shop or not shop.is_installed:
logger.warning("Shopify shop not found or not installed")
return None
# Verify the shop belongs to the current organization
org_uuid = UUID(str(self.org_id))
if str(shop.organization_id) != str(org_uuid):
logger.warning(f"Shop {shop.id} does not belong to organization {self.org_id}")
return None
return shop
except Exception as e:
logger.error(f"Error getting shop for agent: {str(e)}")
return None
def list_products(self, limit: int = 10) -> str:
"""
List products from the Shopify store.
Args:
limit: Maximum number of products to return (default: 10)
Returns:
str: JSON string with the list of products or error information
"""
try:
# Get the Shopify shop for this agent
shop = self._get_shop_for_agent()
if not shop:
return json.dumps({
"success": False,
"message": "Shopify integration is not enabled for this agent or shop not found"
})
# List products using context manager for database operations
with SessionLocal() as db:
shopify_service = ShopifyService(db)
result = shopify_service.get_products(shop, limit)
# Convert to more LLM-friendly text format
if result.get("success") and result.get("products"):
products = result["products"]
# Try to cache products in Redis
redis_client = get_redis()
product_cache_key = None
if redis_client:
try:
# Use fixed cache key per session with org_id for multi-tenant isolation
# Format: {org_id}:shopify_products:{session_id}
product_cache_key = self.product_cache_key
# Store full products in Redis with 5-minute TTL
# setex will overwrite any previous value automatically
redis_client.setex(
product_cache_key,
300, # 5 minutes
json.dumps({
"products": products,
"shop_domain": shop.shop_domain
})
)
logger.debug(f"Cached {len(products)} listed products with key: {product_cache_key}")
self.has_cached_products = True
except Exception as e:
logger.error(f"Failed to cache listed products in Redis: {str(e)}")
product_cache_key = None
# Return minimal products to LLM (reduce tokens while allowing reasoning)
# Full products are in Redis cache
product_ids = [p.get("id") for p in products]
minimal_products = [create_minimal_product(p) for p in products]
return json.dumps({
"success": True,
"message": f"Here are {len(products)} products from the store.",
"shopify_output": {
"products": minimal_products,
"product_cache_key": product_cache_key,
"product_ids": product_ids,
"total_count": len(products),
"shop_domain": shop.shop_domain
}
})
return json.dumps(result)
except Exception as e:
logger.error(f"Error listing products: {str(e)}")
return json.dumps({
"success": False,
"message": f"Error listing products: {str(e)}"
})
def get_product(self, product_id: str) -> str:
"""
Get a specific product from the Shopify store.
Args:
product_id: The ID of the product to retrieve
Returns:
str: JSON string with the product details or error information
"""
try:
# Get the Shopify shop for this agent
shop = self._get_shop_for_agent()
if not shop:
return json.dumps({
"success": False,
"message": "Shopify integration is not enabled for this agent or shop not found"
})
# Get the product using context manager for database operations
with SessionLocal() as db:
shopify_service = ShopifyService(db)
result = shopify_service.get_product(shop, product_id)
# Check if product was found
if result.get("success", False) and result.get("product"):
product = result.get("product")
tags_str = ", ".join(product.get("tags", [])) if product.get("tags") else "None"
# Create detailed text description for LLM
text_message = (
f"Product Details:\n\n"
f"Title: {product.get('title', 'N/A')}\n"
f"Price: {product.get('currency', 'USD')} {product.get('price', 'N/A')}\n"
f"Vendor: {product.get('vendor', 'N/A')}\n"
f"Type: {product.get('product_type', 'N/A')}\n"
f"In Stock: {product.get('total_inventory', 'N/A')} units\n"
f"Tags: {tags_str}\n"
)
if product.get('description'):
text_message += f"Description: {product['description']}\n"
# Format as a product card
return json.dumps({
"success": True,
"message": text_message,
"shopify_product": product,
"shop_domain": shop.shop_domain # Add shop domain for frontend URL construction
})
else:
# Product not found or error
return json.dumps(result)
except Exception as e:
logger.error(f"Error getting product: {str(e)}")
return json.dumps({
"success": False,
"message": f"Error getting product: {str(e)}"
})
def search_products(self, query: str, limit: int = 8, cursor: Optional[str] = None, min_price: Optional[float] = None, max_price: Optional[float] = None, vendor: Optional[str] = None) -> str:
"""
Search for products in the Shopify store using a query string with GraphQL.
Supports Shopify's search syntax including field specifiers (e.g., 'title:snowboard', 'tag:beginner', 'product_type:skate'),
operators ('AND', 'OR'), and suffix wildcards ('skate*'). Supports pagination using a cursor.
Args:
query: The search query string using Shopify syntax.
limit: Maximum number of products to return (default: 8)
cursor: The pagination cursor (endCursor from previous pageInfo) to fetch the next page.
min_price: Optional minimum price filter (e.g., 100.00 for products >= $100)
max_price: Optional maximum price filter (e.g., 500.00 for products <= $500)
vendor: Optional vendor/brand filter (e.g., 'Nike', 'Adidas')
Returns:
str: JSON string. If products are found, includes 'shopify_output' with the list of products,
'search_query', 'total_count', and 'pageInfo'. If no products are found, returns a success message.
On error, returns an error message.
"""
try:
# Get the Shopify shop for this agent
shop = self._get_shop_for_agent()
if not shop:
return json.dumps({
"success": False,
"message": "Shopify integration is not enabled for this agent or shop not found"
})
# Use GraphQL to search products with pagination
graphql_query = """
query SearchProducts($searchTerm: String!, $limit: Int!, $cursor: String) {
products(first: $limit, query: $searchTerm, after: $cursor) {
edges {
node {
id
title
description
handle
productType
vendor
totalInventory
priceRangeV2 {
minVariantPrice {
amount
currencyCode
}
maxVariantPrice {
amount
currencyCode
}
}
images(first: 1) {
edges {
node {
id
url
altText
}
}
}
tags
createdAt
updatedAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
# Add status:active filter to only return active products (exclude archived and draft)
# Add inventory_total:>0 to only return in-stock products
filtered_query = f"({query}) AND status:active AND inventory_total:>0"
# Add price filters if provided
if min_price is not None:
filtered_query += f" AND price:>={min_price}"
if max_price is not None:
filtered_query += f" AND price:<={max_price}"
# Add vendor filter if provided
if vendor:
filtered_query += f" AND vendor:'{vendor}'"
variables = {
"searchTerm": filtered_query,
"limit": limit,
"cursor": cursor if cursor else None # Pass None instead of empty string
}
# Execute the GraphQL query
url = shopify_admin_url(shop.shop_domain, "graphql.json")
headers = {
"X-Shopify-Access-Token": shop.access_token,
"Content-Type": "application/json"
}
payload = {
"query": graphql_query,
"variables": variables
}
verify_ssl = settings.VERIFY_SSL_CERTIFICATES
response = requests.post(url, headers=headers, json=payload, verify=verify_ssl)
if response.status_code != 200:
logger.error(f"Failed to search products: {response.text}")
return json.dumps({
"success": False,
"message": f"Failed to search products: {response.text}"
})
result = response.json()
# Check for GraphQL errors
if "errors" in result:
logger.error(f"GraphQL errors: {result['errors']}")
return json.dumps({
"success": False,
"message": f"GraphQL errors: {result['errors']}"
})
# Transform the GraphQL data structure
products_data = result.get("data", {}).get("products", {})
edges = products_data.get("edges", [])
page_info = products_data.get("pageInfo", {})
# Extract products from edges
products = []
for edge in edges:
node = edge.get("node", {})
# Extract the first image URL if available
image_url = None
images = node.get("images", {}).get("edges", [])
if images and len(images) > 0:
image_url = images[0].get("node", {}).get("url")
# Extract price information
price_range = node.get("priceRangeV2", {})
min_price = price_range.get("minVariantPrice", {}).get("amount")
max_price = price_range.get("maxVariantPrice", {}).get("amount")
currency_code = price_range.get("minVariantPrice", {}).get("currencyCode")
# Transform to product structure
transformed_product = {
"id": node.get("id").split("/")[-1] if node.get("id") else None,
"title": node.get("title"),
"description": node.get("description"),
"handle": node.get("handle"),
"product_type": node.get("productType"),
"vendor": node.get("vendor"),
"total_inventory": node.get("totalInventory"),
"price": min_price,
"price_max": max_price,
"currency": currency_code,
"image": {"src": image_url} if image_url else None,
"tags": node.get("tags"),
"created_at": node.get("createdAt"),
"updated_at": node.get("updatedAt")
}
products.append(transformed_product)
# Check if search was successful
if products:
# Try to cache products in Redis
redis_client = get_redis()
product_cache_key = None
if redis_client:
try:
# Use fixed cache key per session with org_id for multi-tenant isolation
# Format: {org_id}:shopify_products:{session_id}
product_cache_key = self.product_cache_key
# Store full products in Redis with 5-minute TTL
# setex will overwrite any previous value automatically
redis_client.setex(
product_cache_key,
300, # 5 minutes
json.dumps({
"products": products,
"search_query": query,
"total_count": len(products),
"pageInfo": page_info,
"shop_domain": shop.shop_domain
})
)
logger.debug(f"Cached {len(products)} products with key: {product_cache_key}")
self.has_cached_products = True
except Exception as e:
logger.error(f"Failed to cache products in Redis: {str(e)}")
product_cache_key = None
# Return minimal products to LLM (reduce tokens while allowing reasoning)
# Full products are in Redis cache
text_message = f"Found {len(products)} product(s) matching your search."
product_ids = [p.get("id") for p in products]
minimal_products = [create_minimal_product(p) for p in products]
return json.dumps({
"success": True,
"message": text_message,
"shopify_output": {
"products": minimal_products, # Minimal products for LLM reasoning
"product_cache_key": product_cache_key, # Cache key for backend optimization
"product_ids": product_ids, # Product IDs for reference
"search_query": query,
"total_count": len(products),
"pageInfo": page_info,
"shop_domain": shop.shop_domain
}
})
else:
# No products found
return json.dumps({
"success": True,
"message": f"No products found matching '{query}'. Try a different search term."
# Keep shopify_output or make it null/empty based on frontend handling
# "shopify_output": {"products": [], "search_query": query}
})
except Exception as e:
logger.error(f"Error searching products: {str(e)}")
logger.error(traceback.format_exc())
return json.dumps({
"success": False,
"message": f"Error searching products: {str(e)}"
})
def search_orders(self, customer_email: str, order_number: Optional[str] = None, limit: int = MAX_ORDER_RESULTS) -> str:
"""
Find the customer's own orders. Ask the customer for the email address they
used at checkout; never guess it or take it from anywhere else.
Args:
customer_email: The email address the customer used at checkout (required)
order_number: The order number, e.g. "#1001" (optional, narrows to one order)
limit: Maximum number of orders to return (default and maximum: 10)
Returns:
str: JSON string with the customer's matching orders or error information
"""
try:
problem = order_lookup_problem(customer_email, order_number)
if problem:
return json.dumps({"success": False, "message": problem, "requires_user_input": True})
shop = self._get_shop_for_agent()
if not shop:
return json.dumps({
"success": False,
"message": "Shopify integration is not enabled for this agent or shop not found"
})
found = self._find_customer_orders(shop, customer_email, order_number, limit)
if not found["success"]:
return json.dumps(found)
orders = found["orders"]
if not orders:
return json.dumps({"success": False, "message": ORDER_NOT_FOUND})
return json.dumps({
"success": True,
"message": self._summarise_orders(orders),
"orders": orders,
"page_info": found["page_info"],
"shop_domain": shop.shop_domain
})
except Exception as e:
logger.error(f"Error searching orders: {str(e)}")
logger.error(traceback.format_exc())
return json.dumps({"success": False, "message": "Couldn't search the store's orders right now."})
def _find_customer_orders(self, shop: ShopifyShop, customer_email: str,
order_number: Optional[str], limit: int) -> Dict[str, Any]:
"""Orders placed with this email (and number, if given). Shopify's search is
fuzzy, so every result is re-checked for an exact email and number match."""
limit = max(1, min(int(limit or MAX_ORDER_RESULTS), MAX_ORDER_RESULTS))
response = requests.post(
shopify_admin_url(shop.shop_domain, "graphql.json"),
headers={"X-Shopify-Access-Token": shop.access_token, "Content-Type": "application/json"},
json={
"query": ORDER_SEARCH_GRAPHQL,
"variables": {"query": build_order_search(customer_email, order_number), "first": limit},
},
verify=settings.VERIFY_SSL_CERTIFICATES,
timeout=ORDER_SEARCH_TIMEOUT_SECONDS,
)
if response.status_code != 200:
logger.error(f"Failed to search orders: {response.status_code} {response.text}")
return {"success": False, "message": "Couldn't reach the store's orders right now."}
result = response.json()
if "errors" in result:
logger.error(f"GraphQL errors: {result['errors']}")
return {"success": False, "message": "Couldn't search the store's orders right now."}
orders_data = (result.get("data") or {}).get("orders") or {}
orders = [self._order_from_node(edge.get("node") or {}) for edge in orders_data.get("edges", [])]
return {
"success": True,
"orders": [o for o in orders
if order_belongs_to(o, customer_email) and order_number_matches(o, order_number)],
"page_info": orders_data.get("pageInfo", {}),
}
@staticmethod
def _order_from_node(node: Dict[str, Any]) -> Dict[str, Any]:
"""Flatten one GraphQL order node into the shape the agent receives."""
line_items = []
for item_edge in (node.get("lineItems") or {}).get("edges", []):
item_node = item_edge.get("node") or {}
variant = item_node.get("variant") or {}
unit_price = (item_node.get("originalUnitPriceSet") or {}).get("shopMoney") or {}
try:
price_amount = float(unit_price["amount"]) if unit_price.get("amount") is not None else None
except (ValueError, TypeError):
price_amount = None
line_items.append({
"title": item_node.get("title"),
"quantity": item_node.get("quantity"),
"price": price_amount,
"variant_title": variant.get("title"),
"image_url": (variant.get("image") or {}).get("url")
})
tracking_info = [
{"company": tracking.get("company"), "number": tracking.get("number"), "url": tracking.get("url")}
for fulfillment in node.get("fulfillments") or []
for tracking in fulfillment.get("trackingInfo") or []
]
def money(field: str) -> Dict[str, Any]:
return (node.get(field) or {}).get("shopMoney") or {}
customer = node.get("customer") or {}
return {
"id": node["id"].split("/")[-1] if node.get("id") else None,
"name": node.get("name"),
"email": node.get("email"),
"phone": node.get("phone"),
"created_at": node.get("createdAt"),
"financial_status": node.get("displayFinancialStatus"),
"fulfillment_status": node.get("displayFulfillmentStatus"),
"total_items": node.get("subtotalLineItemsQuantity"),
"current_total": money("currentTotalPriceSet").get("amount"),
"original_total": money("originalTotalPriceSet").get("amount"),
"subtotal": money("currentSubtotalPriceSet").get("amount"),
"currency": money("currentTotalPriceSet").get("currencyCode"),
"customer": {
"id": customer.get("id"),
"first_name": customer.get("firstName"),
"last_name": customer.get("lastName"),
"email": customer.get("email")
},
"line_items": line_items,
"shipping_address": node.get("shippingAddress"),
"tracking_info": tracking_info
}
@staticmethod
def _summarise_orders(orders: List[Dict[str, Any]]) -> str:
"""Short text version of the orders for the LLM."""
summaries = []
for idx, order in enumerate(orders, 1):
customer_name = f"{order['customer'].get('first_name') or ''} {order['customer'].get('last_name') or ''}".strip()
summary = (
f"{idx}. Order {order['name']}\n"
f" - Customer: {customer_name or order.get('email') or 'N/A'}\n"
f" - Total: {order['currency']} {order['current_total']}\n"
f" - Status: {order['financial_status']}\n"
f" - Fulfillment: {order['fulfillment_status']}\n"
f" - Items: {order['total_items']}\n"
f" - Created: {order['created_at']}"
)
tracking_numbers = [t["number"] for t in order["tracking_info"] if t.get("number")]
if tracking_numbers:
summary += f"\n - Tracking: {', '.join(tracking_numbers)}"
summaries.append(summary)
return f"Found {len(orders)} order(s):\n\n" + "\n\n".join(summaries)
def get_order_status(self, order_number: str, customer_email: str) -> str:
"""
Get the status and tracking of one order. Needs both the order number and the
email address the customer used at checkout; ask the customer for whichever is
missing. Never guess the email or take it from anywhere else.
Args:
order_number: The order number, e.g. "1001" or "#1001"
customer_email: The email address the customer used at checkout
Returns:
str: JSON string with order status details or error information
"""
try:
problem = order_lookup_problem(customer_email, order_number, number_required=True)
if problem:
return json.dumps({"success": False, "message": problem, "requires_user_input": True})
shop = self._get_shop_for_agent()
if not shop:
return json.dumps({
"success": False,
"message": "Shopify integration is not enabled for this agent or shop not found"
})
found = self._find_customer_orders(shop, customer_email, order_number, MAX_ORDER_RESULTS)
if not found["success"]:
return json.dumps(found)
if not found["orders"]:
return json.dumps({"success": False, "message": ORDER_NOT_FOUND})
with SessionLocal() as db:
result = ShopifyService(db).get_order(shop, found["orders"][0]["id"])
order = result.get("order") if result.get("success", False) else None
if not order:
result["shop_domain"] = shop.shop_domain
return json.dumps(result)
# The full record is fetched separately, so check ownership on it too.
if not order_belongs_to(order, customer_email):
return json.dumps({"success": False, "message": ORDER_NOT_FOUND})
# Filter out cancelled fulfillments - only include active ones
active_fulfillments = [
f for f in order.get("fulfillments") or []
if f.get("status") and f.get("status").upper() != "CANCELLED"
]
# Extract tracking numbers from active fulfillments only
tracking_numbers = []
for fulfillment in active_fulfillments:
if fulfillment.get("tracking_number"):
tracking_numbers.append(fulfillment.get("tracking_number"))
elif fulfillment.get("tracking_numbers"):
tracking_numbers.extend(fulfillment.get("tracking_numbers"))
# Create text summary for LLM
customer = order.get("customer") or {}
customer_name = f"{customer.get('first_name') or ''} {customer.get('last_name') or ''}".strip()
text_message = (
f"Order Status for {order.get('name')}:\n\n"
f"Customer: {customer_name or customer.get('email') or 'N/A'}\n"
f"Total: {order.get('currency', 'USD')} {order.get('total_price')}\n"
f"Payment Status: {order.get('financial_status')}\n"
f"Fulfillment Status: {order.get('fulfillment_status')}\n"
f"Order Date: {order.get('created_at')}\n"
)
if tracking_numbers:
text_message += f"Tracking Numbers: {', '.join(tracking_numbers)}\n"
shipping_addr = order.get("shipping_address")
if shipping_addr:
addr_parts = [
shipping_addr.get("address1"),
shipping_addr.get("city"),
shipping_addr.get("provinceCode"),
shipping_addr.get("zip"),
shipping_addr.get("country")
]
text_message += f"Shipping Address: {', '.join(p for p in addr_parts if p)}\n"
return json.dumps({
"success": True,
"message": text_message,
"order_id": order.get("id"),
"order_number": order.get("name"),
"status": order.get("financial_status"),
"fulfillment_status": order.get("fulfillment_status"),
"created_at": order.get("created_at"),
"processed_at": order.get("processed_at"),
"customer": customer,
"total_price": order.get("total_price"),
"currency": order.get("currency"),
"fulfillments": active_fulfillments,
"shipping_address": shipping_addr,
"billing_address": order.get("billing_address"),
"tracking_numbers": tracking_numbers,
"shop_domain": shop.shop_domain
})
except Exception as e:
logger.error(f"Error getting order status: {str(e)}")
logger.error(traceback.format_exc())
return json.dumps({"success": False, "message": "Couldn't look up that order right now."})
def recommend_products(self, product_id: Optional[str] = None, product_type: Optional[str] = None, tags: Optional[str] = None, limit: int = 8, cursor: Optional[str] = None, min_price: Optional[float] = None, max_price: Optional[float] = None, vendor: Optional[str] = None) -> str:
"""
Recommend products based on similarity to a reference product ID, product type, or tags.
Constructs a Shopify search query based on the provided criteria using GraphQL. Supports pagination.
Use this when a user asks for recommendations (e.g., "suggest something similar", "show me beginner skateboards").
Translate the user's request into relevant product_type or tags if possible.
Args:
product_id: ID of a reference product. Recommendations will be based on this product's type and tags.
product_type: Product type to base recommendations on (e.g., 'skateboard', 'snowboard').
tags: Comma-separated list of tags to base recommendations on (e.g., 'beginner,skate', 'winter,accessory').
limit: Maximum number of recommendations to return (default: 8).
cursor: The pagination cursor (endCursor from previous pageInfo) to fetch the next page.
min_price: Optional minimum price filter (e.g., 100.00 for products >= $100)
max_price: Optional maximum price filter (e.g., 500.00 for products <= $500)
vendor: Optional vendor/brand filter (e.g., 'Nike', 'Adidas')
Returns:
str: JSON string. If recommendations are found, includes 'shopify_output' with the list of products,
'search_type', 'total_count', and 'pageInfo'. If none found, returns a relevant message.
On error, returns an error message.
"""
try:
shop = self._get_shop_for_agent()
if not shop:
return json.dumps({"success": False, "message": "Shopify integration is not enabled for this agent or shop not found"})
search_query_parts = []
search_limit = limit
if product_id:
# Fetch reference product to get its type and tags using GraphQL
ref_product = None
# Use GraphQL to get product details
graphql_query = """
query GetProduct($id: ID!) {
product(id: $id) {
id
title
productType
tags
}
}
"""
gid = f"gid://shopify/Product/{product_id}"
variables = {
"id": gid
}
url = shopify_admin_url(shop.shop_domain, "graphql.json")
headers = {
"X-Shopify-Access-Token": shop.access_token,
"Content-Type": "application/json"
}
payload = {
"query": graphql_query,
"variables": variables
}
verify_ssl = settings.VERIFY_SSL_CERTIFICATES
response = requests.post(url, headers=headers, json=payload, verify=verify_ssl)
if response.status_code == 200:
result = response.json()
if "data" in result and "product" in result["data"] and result["data"]["product"]:
ref_product = result["data"]["product"]
if ref_product:
ref_type = ref_product.get("productType")
ref_tags = ref_product.get("tags", [])
if ref_type:
search_query_parts.append(f"product_type:'{ref_type}'")
if ref_tags:
# Handle tags as a list
if isinstance(ref_tags, list):
for tag in ref_tags[:3]: # Use up to 3 tags for recommendations
search_query_parts.append(f"tag:'{tag}'")
elif isinstance(ref_tags, str):
tags_list = [tag.strip() for tag in ref_tags.split(',') if tag.strip()]
for tag in tags_list[:3]:
search_query_parts.append(f"tag:'{tag}'")
# Exclude the reference product itself
search_query_parts.append(f"-id:{product_id}")
search_limit = limit + 1
else:
logger.warning(f"Could not fetch reference product ID: {product_id} for recommendations.")
# Add criteria from direct arguments if no product_id or if fallback needed
if not product_id or not search_query_parts:
if product_type:
search_query_parts.append(f"product_type:'{product_type}'")
if tags:
tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
for tag in tag_list[:3]:
search_query_parts.append(f"tag:'{tag}'")
# Determine search type and query
if not search_query_parts:
# Fallback: Get recent products if no criteria specified
logger.info("No specific criteria for recommendations, fetching recent products.")
# Build fallback query with price and vendor filters
fallback_filters = ["status:active", "inventory_total:>0"]
if min_price is not None:
fallback_filters.append(f"price:>={min_price}")
if max_price is not None:
fallback_filters.append(f"price:<={max_price}")
if vendor:
fallback_filters.append(f"vendor:'{vendor}'")
fallback_query = " AND ".join(fallback_filters)
# Use GraphQL to get recent products with pagination
# Filter to only return active, in-stock products
graphql_query = f"""
query GetProducts($limit: Int!, $cursor: String) {{
products(first: $limit, query: "{fallback_query}", sortKey: CREATED_AT, reverse: true, after: $cursor) {{
edges {{
node {{
id
title
description
handle
productType
vendor
totalInventory
priceRangeV2 {{
minVariantPrice {{
amount
currencyCode
}}
maxVariantPrice {{
amount
currencyCode
}}
}}
images(first: 1) {{
edges {{
node {{
id
url
altText
}}
}}
}}
tags
createdAt
updatedAt
}}
}}
pageInfo {{
hasNextPage
endCursor
}}
}}
}}
"""
variables = {
"limit": limit,
"cursor": cursor if cursor else None
}
search_type = "recent products"
else:
# Construct final query: Join parts with OR for broader recommendations
# Add status:active filter to only return active products (exclude archived and draft)
# Add inventory_total:>0 to only return in-stock products
search_query = f"({' OR '.join(search_query_parts)}) AND status:active AND inventory_total:>0"
# Add price filters if provided
if min_price is not None:
search_query += f" AND price:>={min_price}"
if max_price is not None:
search_query += f" AND price:<={max_price}"
# Add vendor filter if provided
if vendor:
search_query += f" AND vendor:'{vendor}'"
logger.info(f"Constructed recommendation search query: {search_query}")
# Use GraphQL to search products with pagination
graphql_query = """
query SearchProducts($searchTerm: String!, $limit: Int!, $cursor: String) {
products(first: $limit, query: $searchTerm, after: $cursor) {
edges {
node {
id
title
description
handle
productType
vendor
totalInventory
priceRangeV2 {
minVariantPrice {
amount
currencyCode
}
maxVariantPrice {
amount
currencyCode
}
}
images(first: 1) {
edges {
node {
id
url
altText
}
}
}
tags
createdAt
updatedAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
variables = {
"searchTerm": search_query,
"limit": search_limit,
"cursor": cursor if cursor else None
}
search_type = "recommendations"
# Execute the GraphQL query
url = shopify_admin_url(shop.shop_domain, "graphql.json")
headers = {
"X-Shopify-Access-Token": shop.access_token,
"Content-Type": "application/json"
}
payload = {
"query": graphql_query,
"variables": variables
}
verify_ssl = settings.VERIFY_SSL_CERTIFICATES
response = requests.post(url, headers=headers, json=payload, verify=verify_ssl)
if response.status_code != 200:
logger.error(f"Failed to get recommendations: {response.text}")
return json.dumps({
"success": False,
"message": f"Failed to get recommendations: {response.text}"
})
result = response.json()
# Check for GraphQL errors
if "errors" in result:
logger.error(f"GraphQL errors: {result['errors']}")
return json.dumps({
"success": False,
"message": f"GraphQL errors: {result['errors']}"
})
# Transform the GraphQL data structure
products_data = result.get("data", {}).get("products", {})
edges = products_data.get("edges", [])
page_info = products_data.get("pageInfo", {})
# Extract products from edges
products = []
for edge in edges:
node = edge.get("node", {})
# Extract the first image URL if available
image_url = None
images = node.get("images", {}).get("edges", [])
if images and len(images) > 0:
image_url = images[0].get("node", {}).get("url")
# Extract price information
price_range = node.get("priceRangeV2", {})
min_price = price_range.get("minVariantPrice", {}).get("amount")
max_price = price_range.get("maxVariantPrice", {}).get("amount")
currency_code = price_range.get("minVariantPrice", {}).get("currencyCode")
# Transform to product structure
transformed_product = {
"id": node.get("id").split("/")[-1] if node.get("id") else None,
"title": node.get("title"),
"description": node.get("description"),
"handle": node.get("handle"),
"product_type": node.get("productType"),
"vendor": node.get("vendor"),
"total_inventory": node.get("totalInventory"),
"price": min_price,
"price_max": max_price,
"currency": currency_code,
"image": {"src": image_url} if image_url else None,
"tags": node.get("tags"),
"created_at": node.get("createdAt"),
"updated_at": node.get("updatedAt")
}
products.append(transformed_product)
# Filter out the original product_id if it was part of the criteria and fetch limit allowed
if product_id and search_limit > limit:
original_gid = f"gid://shopify/Product/{product_id}"
products = [p for p in products if p.get("id") != original_gid and p.get("id") != product_id][:limit]
# Process results
if products:
# Try to cache products in Redis
redis_client = get_redis()
product_cache_key = None
if redis_client:
try:
# Use fixed cache key per session with org_id for multi-tenant isolation
# Format: {org_id}:shopify_products:{session_id}
product_cache_key = self.product_cache_key
# Store full products in Redis with 5-minute TTL
# setex will overwrite any previous value automatically
redis_client.setex(
product_cache_key,
300, # 5 minutes
json.dumps({
"products": products,
"search_type": search_type,
"total_count": len(products),
"pageInfo": page_info,
"shop_domain": shop.shop_domain
})
)
logger.debug(f"Cached {len(products)} recommended products with key: {product_cache_key}")
self.has_cached_products = True
except Exception as e:
logger.error(f"Failed to cache recommended products in Redis: {str(e)}")
product_cache_key = None
# Return minimal products to LLM (reduce tokens while allowing reasoning)
# Full products are in Redis cache
product_ids = [p.get("id") for p in products]
minimal_products = [create_minimal_product(p) for p in products]
# Customize message header based on initial criteria
recommendation_header = f"Here are {len(products)} recommendations for you."
if product_type and not product_id:
recommendation_header = f"Here are recommendations in the {product_type} category."
elif tags and not product_id:
recommendation_header = f"Here are products tagged with {tags}."
elif product_id:
recommendation_header = f"Based on the product you viewed, you might like these options."
return json.dumps({
"success": True,
"message": recommendation_header,
"shopify_output": {
"products": minimal_products, # Minimal products for LLM reasoning
"product_cache_key": product_cache_key, # Cache key for backend optimization
"product_ids": product_ids, # Product IDs for reference
"search_type": search_type,
"total_count": len(products),
"pageInfo": page_info,
"shop_domain": shop.shop_domain
}
})
# No products found or initial search failed
no_results_message = "Sorry, I couldn't find any product recommendations matching the criteria."
# Customize message based on input
if product_type: no_results_message = f"Sorry, I couldn't find recommendations in the {product_type} category."
elif tags: no_results_message = f"Sorry, I couldn't find recommendations matching the tags: {tags}."
elif product_id: no_results_message = "Sorry, I couldn't find similar products based on the reference product."
return json.dumps({
"success": True,
"message": no_results_message
# Keep shopify_output or make it null/empty based on frontend handling
# "shopify_output": {"products": [], "search_type": search_type}
})
except Exception as e:
logger.error(f"Error recommending products: {str(e)}")
logger.error(traceback.format_exc())
return json.dumps({
"success": False,
"message": f"An error occurred while generating recommendations: {str(e)}"
})