dialogflow-prebuilt-agents/cloud-functions/retail_assistant/main.py (398 lines of code) (raw):

# Copyright 2025 Google LLC # # 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. # The original author of this file is unreachable, and there is no # test-case for this file, hence it is not feasible to lint this file # pylint: skip-file """Google Cloud Function to return JSON or HTML from search response This sample uses the Retail API with client libraries This can be used for doing AJAX/client side calls to get search results and render in a div. Configure the GCF to use a service account which has Retail Editor Role """ import datetime import datetime import os import random import tomllib import uuid import firebase_admin from firebase_functions import https_fn import flask from google.api_core.gapic_v1 import client_info import google.auth from google.cloud import firestore from google.cloud import retail_v2 from google.protobuf import json_format timedelta = datetime.timedelta datetime = datetime.datetime ClientInfo = client_info.ClientInfo initialize_app = firebase_admin.initialize_app request = flask.request MessageToDict = json_format.MessageToDict # User-Agent: cloud-solutions/conversational-commerce-agent-v0.0.1 TOML_PATH = os.getenv("CONFIG_TOML_PATH", "config.toml") with open(TOML_PATH, "rb") as f: config = tomllib.load(f) initialize_app() app = flask.Flask(__name__) # Create a client to interact with Firestore db = firestore.Client( project=config["gcp"]["project_id"], database=config["gcp"]["apparel_firebase_db"], ) # GCP Project Number PROJECT_NUMBER = config["gcp"]["project_number"] # Retail API auth scopes credentials, project = google.auth.default( scopes=["https://www.googleapis.com/auth/cloud-platform"], quota_project_id=config["gcp"]["project_id"], ) auth_req = google.auth.transport.requests.Request() client = retail_v2.SearchServiceClient( credentials=credentials, client_info=ClientInfo( "cloud-solutions/conversational-shopping-agent-v0.0.1" ), ) predict_client = retail_v2.PredictionServiceClient(credentials=credentials) product_client = retail_v2.ProductServiceClient(credentials=credentials) product_name = ( "projects/" + PROJECT_NUMBER + "/locations/global/catalogs/default_catalog/branches/1/products/" ) # For now for demo purposes we are just returning the same example reviews regardless of the product. REVIEWS = [ { "product_id": "", "user": "Briene Sanje", "rating": 3, "desc": ( "the ${title} fit real well at first, I suppose. However after a" " few hours into the night I started to wish I had" " gone up a size or two. Every couple minutes I had to " " re-adjust. It wasn't a cute look on the dance floor haha. Wish I" " had size up, otherwise I've no other regrets." ), "title": "", }, { "product_id": "", "user": "Jenny Rahme", "rating": 5, "desc": ( "As a first-time buyer, I must say, I am head over heels in love" " with my purchase! The moment I delicately wear the" " ${title}, I felt an instant connection, as if it" " was meant to be. What impressed me the most was the" " comfort it provded throughout the day." ), "title": "", }, { "product_id": "", "user": "Nadine Bhakta", "rating": 4, "desc": ( "I recently had the pleasure of wearing the most enchanting" " ${title} for a special occasion, and let me tell" " you, heads turned and compliments poured in all" " night long! The fit was mostly great." ), "title": "", }, { "product_id": "", "user": "Anjali Gupta", "rating": 5, "desc": ( "I recently purchased the ${title} from this store, and I must say," " it's absolutely stunning! The quality is premium," " and the work is a testament to the remarkable craftsmanship. " " The product is beautiful, and the rich color hasn't" " faded a bit even after a few wears." ), "title": "", }, ] # This acts as a simple "database" for demonstration purposes user_info = { "delivery_address": "638 Maple Street, Apt 11, Cupertino, CA 95014", "payment_info": "**********4111", "contact_number": "416-555-6704", "email": "poetry_reader456@gmail.com", } @app.post("/search") def search(): """returns products based on a search query from product catalog.""" app.logger.warning("REACHED /SEARCH") request_json = request.get_json(silent=False) app.logger.warning("REQUEST: %s", request_json) # Capture the user's search query. query = request_json["search"] start_index = request_json[ "offset" ] # index number from which the products should be returned session_id = str(uuid.uuid4()) visitorid = session_id placement = ( # A search model name which was configured while creating product catalog "default_search" ) # Retail API search request search_request = { "placement": ( "projects/" + PROJECT_NUMBER + "/locations/global/catalogs/default_catalog/placements/" + placement ), "query": query, "visitor_id": visitorid, "query_expansion_spec": {"condition": "AUTO"}, } try: # Retail Search API call response = client.search(search_request) res = MessageToDict(response._pb) app.logger.warning("RAW RESULT: %s", res["results"]) # extract products based on the offset index from the returned products if start_index > len(res["results"]) - 1: return flask.jsonify({"message": "No more products available to show"}) num_products = 3 # number of products to display in the UI end_index = start_index + num_products end_index = ( end_index if len(res["results"]) > end_index else len(res["results"]) ) products = res["results"][start_index:end_index] # remove unnecessary fields from product's data data = get_minimal_payload(products) app.logger.warning("RESULT: %s", data) # Transform the product's data into a customer template format to display in the UI response = generate_custom_template(data) return flask.jsonify(response) except Exception as e: app.logger.warning("Retail Search Exception: %s", e) return flask.jsonify({}) @app.post("/get_product_details") def get_product_details(): """Fetch products details to answer customer's queries related to products.""" app.logger.warning("REACHED /GET_PRODUCT_DETAILS") request_json = request.get_json(silent=False) app.logger.warning("REQUEST: %s", request_json) product_ids = list(set(request_json["product_ids"])) app.logger.warning("PRODUCT_IDS: %s", product_ids) data = [] for product_id in product_ids: # Retail API call product = product_client.get_product(name=product_name + product_id) product.retrievable_fields = None res = MessageToDict(product._pb) obj = {"product": res} data.append(obj) app.logger.warning("RAW RESULT: %s", data) # Keep only necessary fields from product's data product_details = get_minimal_payload(data) app.logger.warning("PRODUCTS: %s", product_details) return flask.jsonify({"products": product_details}) @app.post("/similar") def similar(): """Find similar products using Retail Recommendation API.""" app.logger.warning("REACHED /SIMILAR") request_json = request.get_json(silent=False) app.logger.warning("REQUEST: %s", request_json) product_id = request_json["product_id"] page_size = 2 placement = ( # A recommendation model created during product catalog creation. "similar-item" ) user_event = { "event_type": "detail-page-view", "visitor_id": str(uuid.uuid4()), "product_details": [{"product": {"id": product_id}}], } # Retail Recommendation API request predict_request = { "placement": ( "projects/" + PROJECT_NUMBER + "/locations/global/catalogs/default_catalog/servingConfigs/" + placement ), "user_event": user_event, "page_size": page_size, "filter": "filterOutOfStockItems", "params": {"returnProduct": True, "returnScore": True}, } try: # Retail Recommendation API call response = predict_client.predict(predict_request) res = MessageToDict(response._pb) app.logger.warning("RAW RESPONSE: %s", res) app.logger.warning("RAW RESULT: %s", res["results"]) data = res["results"] # Remove unnecessary 'metadata' parent nodes to normalize output between /search and /similar results. for i in range(len(data)): data[i] = data[i]["metadata"] data = get_minimal_payload(data) app.logger.warning("RESULT: %s", data) if len(data) > 0: # Transform product's data into custom template format to display in UI response = generate_custom_template(data) else: response = {} return flask.jsonify(response) except Exception as e: app.logger.warning("Retail Search Exception: %s", e) return flask.jsonify({}) @app.post("/get_reviews") def get_reviews(): """retrieve product's reviews (Currently using DUMMY reviews)""" app.logger.warning("REACHED /GET_REVIEWS") request_json = request.get_json(silent=False) app.logger.warning("REQUEST: %s", request_json) # Eventually this should look up reviews based on the product ID provided. shown_products = request_json["shown_products"] reviews_per_product = len(REVIEWS) // len(shown_products) app.logger.warning("reviews_per_product: %s", reviews_per_product) # splitting the reviews for the shown products reviews = [] for idx, product in enumerate(shown_products): for r in REVIEWS[ idx * reviews_per_product : (idx + 1) * reviews_per_product ]: review = r.copy() review["product_id"] = product["id"] review["title"] = product["title"] review["desc"] = review["desc"].replace("${title}", product["title"]) reviews.append(review) app.logger.warning("REVIEWS: %s", reviews) # Transforming reviews into a customer template format to display in UI response = generate_custom_template(reviews, template="review-template") response["reviews"] = reviews return flask.jsonify(response) @app.post("/get_delivery_date") def get_delivery_date(): """Get the estimated delivery date""" app.logger.warning("REACHED /GET_DELIVERY_DATE") request_json = request.get_json(silent=False) app.logger.warning("REQUEST: %s", request_json) shopping_cart = request_json["shopping_cart"] app.logger.warning("SHOPPING CART: %s", shopping_cart) # set estimated delivery date between next 3 to 7 days dt = datetime.now() td = timedelta(days=random.randint(3, 7)) # your calculated date est_delivery_date = dt + td cart_with_delivery_dates = [] for item in shopping_cart: new_item = item new_item["delivery_date"] = datetime.strftime( est_delivery_date, "%B %d, %Y" ) new_item["earliest_delivery_date"] = datetime.strftime( est_delivery_date, "%B %d, %Y" ) cart_with_delivery_dates.append(new_item) app.logger.warning("CART WITH DELIVERY DATES: %s", cart_with_delivery_dates) return flask.jsonify({"shopping_cart": cart_with_delivery_dates}) @app.post("/store_delivery_date") def store_delivery_date(): """Store the user's preferred delivery date (should be in future than the earliest estimated delivery date)""" app.logger.warning("REACHED /STORE_DELIVERY_DATE") request_json = request.get_json(silent=False) app.logger.warning("REQUEST: %s", request_json) shopping_cart = request_json["shopping_cart"] preferred_delivery_dates = request_json["preferred_delivery_date"] app.logger.warning("SHOPPING CART: %s", shopping_cart) app.logger.warning("PREFERRED DELIVERY DATES: %s", preferred_delivery_dates) if preferred_delivery_dates: for item in preferred_delivery_dates: app.logger.warning("PREFERRED DELIVERY DATE ITEM: %s", item) if item and item["id"] and item["preferred_delivery_date"]: # copy product by value product = ( list(filter(lambda x: x["id"] == item["id"], shopping_cart)) )[0] product_index = shopping_cart.index(product) # copy product by reference product = shopping_cart[product_index] # verify the preferred delivery date is in the future from the earliest delivery date if datetime.strptime( item["preferred_delivery_date"], "%B %d, %Y" ) >= datetime.strptime(product["earliest_delivery_date"], "%B %d, %Y"): # updating product delivery date in the shopping cart product["delivery_date"] = item["preferred_delivery_date"] # modification in preferred_delivery_dates object item["reason"] = ( "The delivery date for the " + product["title"] + " has been changed to " + product["delivery_date"] ) item["status"] = "success" else: # modification in preferred_delivery_dates object item["reason"] = ( "The preferred delivery date (" + item["preferred_delivery_date"] + ") is before the earliest delivery date (" + product["earliest_delivery_date"] + ") for the " + product["title"] ) item["status"] = "fail" app.logger.warning( "DELIVERY DATES CHANGE STATUS: %s", preferred_delivery_dates ) return flask.jsonify({ "shopping_cart": shopping_cart, "preferred_delivery_date": preferred_delivery_dates, }) @app.route("/place_order", methods=["POST"]) def place_order(): """Place the order for the shopping cart items""" app.logger.warning("REACHED /PLACE_ORDER") request_json = request.get_json(silent=True) app.logger.warning("REQUEST: %s", request_json) products = request_json.get("products", []) app.logger.warning("SHOPPING CART PRODUCTS: %s", products) if not products: app.logger.warning("EMPTY CART: %s", products) return flask.jsonify( {"order_status": "not_placed", "reason": "Shopping cart is empty!"} ) order_id = uuid.uuid4().hex[:8] order_created_on = datetime.utcnow().isoformat() + "Z" # Eventually this response should be returned from an API after successful order placement order_data = { "order_id": order_id, "order_status": "confirmed", "order_created_on": order_created_on, "products": products, } try: # store the order data in firestore db.collection("orders").document(order_id).set(order_data) app.logger.warning("ORDER PLACED: %s", order_data) return flask.jsonify(order_data) except Exception as e: app.logger.warning("PLACE ORDER EXCEPTION: %s", e) return flask.jsonify( {"order_status": "not_placed", "reason": "Internal Server Error!"} ) @app.route("/get_user_info", methods=["GET"]) def get_user_info(): """Fetch user's personal info. NOTE: currently using DUMMY user info. In an actual app, this should be coming from user's account. """ app.logger.warning("REACHED /USER_INFO") request_json = request.get_json(silent=True) app.logger.warning("REQUEST: %s", request_json) return flask.jsonify(user_info) @app.route("/update_user_info", methods=["POST"]) def update_user_info(): """Update user's info NOTE: Currently updating in the DUMMY user info, but in an actual app, this should be updated in user's account. """ try: # Parse the incoming data incoming_data = request.get_json() # Update the user_info object with new data when its provided user_info["delivery_address"] = incoming_data.get( "delivery_address", user_info["delivery_address"] ) user_info["payment_info"] = encrypt_payment_info( str(incoming_data.get("payment_info", user_info["payment_info"])) ) user_info["contact_number"] = str( incoming_data.get("contact_number", user_info["contact_number"]) ) user_info["email"] = incoming_data.get("email", user_info["email"]) # Respond with updated user information return flask.jsonify({ "message": "User information updated successfully.", "user_info": user_info, }) except Exception as e: app.logger.warning("USER INFO UPDATE EXCEPTION: %s", e) return flask.jsonify( {"message": "User information did not update successfully."} ) def encrypt_payment_info(payment_info): return "*" * 12 + payment_info[-4:] @app.get("/no_op") def no_op(): """Healthcheck endpoint""" app.logger.warning("REACHED /NO_OP") return flask.jsonify(True) def get_minimal_payload(resp_json): """Returns necessary fields from product's data""" results = [] for item in resp_json: output = {"product": {}} if "id" in item: output["product"]["id"] = item["id"] else: output["product"]["id"] = item["product"]["id"] output["product"]["title"] = item["product"]["title"] output["product"]["name"] = item["product"]["name"] output["product"]["priceInfo"] = item["product"]["priceInfo"] output["product"]["images"] = [item["product"]["images"][0]] output["product"]["description"] = item["product"]["description"] results.append(output) return results def generate_custom_template(payload, template="retail-template"): """This works to return a custom template payload successfully in Playbook -> Tools -> Cloud function. Parameters: payload: The data being returned template: The Custom template for DF Messenger to use to render the rich content. Default is retail-template. Returns: response: The response object which contains custom template payload under "payload" field. """ return { "payload": { "richContent": [[{ "type": "custom_template", "name": template, "payload": {"items": payload}, }]] } } @https_fn.on_request() def main(req: https_fn.Request) -> https_fn.Response: credentials.refresh(auth_req) with app.request_context(req.environ): return app.full_dispatch_request()