diff --git a/samples/js-emailResponder/README.md b/samples/js-emailResponder/README.md new file mode 100644 index 000000000..e82d515c7 --- /dev/null +++ b/samples/js-emailResponder/README.md @@ -0,0 +1,129 @@ +# Customer Service Chatbot with Genkit + +This project demonstrates how to build a customer service chatbot using Genkit, a powerful AI framework for building conversational applications. The chatbot is designed to handle various customer inquiries related to products, orders, and general catalog questions. + +Key features: + +- Uses SQLite as the underlying database for easy setup and portability +- Implements complex flow logic with conditional branching based on customer intent: + - Queries distinct data tables depending on the intent / sub-intent +- Includes evaluation examples using Genkit's evaluation framework to measure: + - Response faithfulness to source data + - Answer relevancy to customer questions +- Ready for deployment on Google Cloud Platform with Google AI integration + +## Prerequisites + +Before you begin, make sure you have the following installed: + +- Node.js (v20 or later) +- npm (v10.5.0 or later) +- Genkit CLI + +## Getting Started + +1. Clone this repository: + + ``` + git clone https://github.com/jeffdh5/customer-service-chatbot.git + cd customer-service-chatbot + ``` + +2. Install dependencies: + + ``` + pnpm i + ``` + +3. Set up your database: + a. Export environment variables: + ``` + export PROJECT_ID=[YOUR PROJECT ID] + export LOCATION=[YOUR LOCATION] + ``` + + c. Set up the database and seed with sample data: + + ``` + cd src/ + npx prisma generate + npx prisma migrate dev --name init + npm run prisma:seed + cd ../ + ``` + +4. Test the chatbot with sample data + + After seeding the database, you can test the chatbot with these example queries that match our seed data: + + ```bash + # In a different terminal + npm run genkit:dev + + # Test the classify inquiry flow to detect intent + genkit flow:run classifyInquiryFlow '{ + "inquiry": "Is the Classic Blue T-Shirt for $19.99 still in stock?" + }' + + # Test e2e CS flow (generates a email response to the input inquiry) + genkit flow:run customerServiceFlow '{ + "from": "john.doe@example.com", + "to": "support@company.com", + "subject": "Product Catalog", + "body": "What products do you have under $50?", + "sentAt": "2024-03-14T12:00:00Z", + "threadHistory": [] + }' + ``` + + ### Seeded Data Reference + + The SQLite database comes pre-seeded with some data so that you can + easily test out your queries. + + #### Products: + + - Classic Blue T-Shirt ($19.99, SKU: BLU-TSHIRT-M) + - Running Shoes ($89.99, SKU: RUN-SHOE-42) + - Denim Jeans ($49.99, SKU: DEN-JEAN-32) + - Leather Wallet ($29.99, SKU: LEA-WALL-01) + - Wireless Headphones ($149.99, SKU: WIR-HEAD-BK) + + #### Customers and Their Orders: + + - John Doe (john.doe@example.com) + - Order TRACK123456: 2 Blue T-Shirts, 1 Running Shoes (DELIVERED) + - Jane Smith (jane.smith@example.com) + - Order TRACK789012: 1 Denim Jeans (PROCESSING) + - Bob Wilson (bob.wilson@example.com) + - Order TRACK345678: 1 Leather Wallet, 1 Wireless Headphones (PENDING) + +5. Run evals + ``` + genkit eval:flow classifyInquiryFlow --input evals/classifyInquiryTestInputs.json + genkit eval:flow generateDraftFlow --input evals/generateDraftTestInputs.json + ``` + +## Project Structure + +The project is structured as follows: + +- `src/`: Contains the main application code +- `prisma/`: Contains the Prisma schema and migrations (for PostgreSQL) +- `prompts/`: Contains the prompt templates for Genkit +- `scripts/`: Contains utility scripts + +## Handler Concept + +The chatbot uses a handler-based architecture to process inquiries: + +1. Inquiry Classification: Categorizes user inquiries +2. Response Generation: Creates draft responses based on classification +3. Human Review (optional): Routes complex inquiries for human review + +This allows you to configure the responder to only handle specific intents. +If there is no handler, then the flow will escalate the conversation. + +It also gives you the flexibility to control the logic for each handler. + +Handlers are modular and extensible, located in `src/handlers/`. diff --git a/samples/js-emailResponder/evals/classifyInquiryTestInputs.json b/samples/js-emailResponder/evals/classifyInquiryTestInputs.json new file mode 100644 index 000000000..257531a25 --- /dev/null +++ b/samples/js-emailResponder/evals/classifyInquiryTestInputs.json @@ -0,0 +1,16 @@ +[ + { "inquiry": "I want to return a product I bought last week" }, + { "inquiry": "What's the status of my order?" }, + { "inquiry": "Do you have this shirt in blue?" }, + { "inquiry": "I need help setting up my account" }, + { "inquiry": "How can I track my package?" }, + { "inquiry": "Is there a warranty on this product?" }, + { "inquiry": "Can I change my shipping address?" }, + { "inquiry": "What are your store hours?" }, + { "inquiry": "Do you offer gift wrapping?" }, + { "inquiry": "How do I apply a coupon code?" }, + { "inquiry": "Are there any ongoing sales or promotions?" }, + { "inquiry": "Can I cancel my subscription?" }, + { "inquiry": "What's your return policy?" }, + { "inquiry": "How long does shipping usually take?" } +] diff --git a/samples/js-emailResponder/evals/generateDraftTestInputs.json b/samples/js-emailResponder/evals/generateDraftTestInputs.json new file mode 100644 index 000000000..1e5a65347 --- /dev/null +++ b/samples/js-emailResponder/evals/generateDraftTestInputs.json @@ -0,0 +1,165 @@ +[ + { + "intent": "Product", + "subintent": "StockAvailability", + "inquiry": "Do you have the blue t-shirt in size medium?", + "context": { + "product": { + "id": "PROD123", + "name": "Classic Blue T-Shirt", + "description": "Comfortable cotton t-shirt in classic blue", + "price": 19.99, + "sku": "BLU-TSHIRT-M", + "stockLevel": 5 + } + }, + "handlerResult": { + "needsUserInput": false, + "nextAction": "DONE", + "actionsTaken": ["Retrieved product details"], + "data": { + "product": { + "id": "PROD123", + "name": "Classic Blue T-Shirt", + "description": "Comfortable cotton t-shirt in classic blue", + "price": 19.99, + "sku": "BLU-TSHIRT-M", + "stockLevel": 5 + } + }, + "handlerCompleted": true + } + }, + { + "intent": "Order", + "subintent": "TrackingStatus", + "inquiry": "Where is my order #ORD456? It's been a week since I ordered.", + "context": { + "customerEmail": "customer@example.com" + }, + "handlerResult": { + "needsUserInput": false, + "nextAction": "DONE", + "actionsTaken": ["Retrieved customer details", "Fetched recent orders"], + "data": { + "customer": { + "id": "CUST789", + "email": "customer@example.com", + "name": "John Doe" + }, + "recentOrders": [ + { + "id": "ORD456", + "orderDate": "2023-05-01T00:00:00Z", + "status": "SHIPPED", + "trackingNumber": "TRACK123456", + "orderItems": [ + { + "id": "ITEM1", + "product": { + "id": "PROD123", + "name": "Classic Blue T-Shirt" + }, + "quantity": 2 + } + ] + } + ] + }, + "handlerCompleted": true + } + }, + { + "intent": "Returns", + "subintent": "ProcessInquiry", + "inquiry": "How do I return the shoes I bought last week? They don't fit.", + "context": { + "customerEmail": "customer@example.com" + }, + "handlerResult": { + "needsUserInput": false, + "nextAction": "DONE", + "actionsTaken": ["Retrieved customer details", "Fetched recent orders"], + "data": { + "customer": { + "id": "CUST789", + "email": "customer@example.com", + "name": "John Doe" + }, + "recentOrders": [ + { + "id": "ORD789", + "orderDate": "2023-04-25T00:00:00Z", + "status": "DELIVERED", + "orderItems": [ + { + "id": "ITEM2", + "product": { + "id": "PROD456", + "name": "Running Shoes" + }, + "quantity": 1 + } + ] + } + ] + }, + "handlerCompleted": true + } + }, + { + "intent": "Account", + "subintent": "LoginIssue", + "inquiry": "I can't log into my account. It says my password is incorrect but I'm sure it's right.", + "context": { + "customerEmail": "customer@example.com" + }, + "handlerResult": { + "needsUserInput": true, + "nextAction": "RESET_PASSWORD", + "actionsTaken": ["Retrieved customer details"], + "data": { + "customer": { + "id": "CUST789", + "email": "customer@example.com", + "name": "John Doe" + } + }, + "handlerCompleted": false + } + }, + { + "intent": "Catalog", + "subintent": "ProductAvailability", + "inquiry": "What products do you have available?", + "context": {}, + "handlerResult": { + "needsUserInput": false, + "nextAction": "DONE", + "actionsTaken": ["Listed available products"], + "data": { + "products": [ + { + "id": "PROD123", + "name": "Classic Blue T-Shirt", + "price": 19.99, + "stockLevel": 5 + }, + { + "id": "PROD456", + "name": "Running Shoes", + "price": 89.99, + "stockLevel": 10 + }, + { + "id": "PROD789", + "name": "Denim Jeans", + "price": 49.99, + "stockLevel": 15 + } + ] + }, + "handlerCompleted": true + } + } +] diff --git a/samples/js-emailResponder/genkit-tools.conf.js b/samples/js-emailResponder/genkit-tools.conf.js new file mode 100644 index 000000000..35fcf0394 --- /dev/null +++ b/samples/js-emailResponder/genkit-tools.conf.js @@ -0,0 +1,54 @@ +/** + * Copyright 2024 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. + */ + +module.exports = { + evaluators: [ + { + flowName: 'generateDraftFlow', + extractors: { + context: (trace) => { + const rootSpan = Object.values(trace.spans).find( + (s) => + s.attributes['genkit:type'] === 'flow' && + s.attributes['genkit:name'] === 'generateDraftFlow' + ); + if (!rootSpan) return JSON.stringify([]); + + const input = JSON.parse(rootSpan.attributes['genkit:input']); + return JSON.stringify([JSON.stringify(input)]); + }, + // Keep the default extractors for input and output + }, + }, + { + flowName: 'classifyInquiryFlow', + extractors: { + context: (trace) => { + const rootSpan = Object.values(trace.spans).find( + (s) => + s.attributes['genkit:type'] === 'flow' && + s.attributes['genkit:name'] === 'classifyInquiryFlow' + ); + if (!rootSpan) return JSON.stringify([]); + + const input = JSON.parse(rootSpan.attributes['genkit:input']); + return JSON.stringify([JSON.stringify(input)]); + }, + // Keep the default extractors for input and output + }, + }, + ], +}; diff --git a/samples/js-emailResponder/package.json b/samples/js-emailResponder/package.json new file mode 100644 index 000000000..17c2500bd --- /dev/null +++ b/samples/js-emailResponder/package.json @@ -0,0 +1,44 @@ +{ + "name": "customer-service-chatbot", + "version": "1.0.0", + "main": "lib/index.js", + "scripts": { + "start": "node lib/index.js", + "genkit:dev": "genkit start -- tsx --watch src/index.ts", + "compile": "tsc", + "build": "npm run build:clean && npm run compile", + "build:clean": "rimraf ./lib", + "build:watch": "tsc --watch", + "build-and-run": "npm run build && node lib/index.js", + "migrate": "prisma migrate dev", + "prisma:seed": "ts-node src/prisma/seed.ts", + "prisma:studio": "prisma studio" + }, + "dependencies": { + "@faker-js/faker": "^7.5.0", + "@genkit-ai/evaluator": "^0.9.9", + "@genkit-ai/google-cloud": "^0.9.9", + "@genkit-ai/googleai": "^0.9.9", + "@genkit-ai/vertexai": "^0.9.9", + "@prisma/client": "^4.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.0", + "genkit": "^0.9.9", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^20.16.5", + "genkit-cli": "^0.9.9", + "nodemon": "^3.1.4", + "prisma": "^4.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.7.2" + }, + "prisma": { + "seed": "ts-node ./prisma/seed.ts" + }, + "packageManager": "pnpm@9.13.2+sha256.ccce81bf7498c5f0f80e31749c1f8f03baba99d168f64590fc7e13fad3ea1938" +} diff --git a/samples/js-emailResponder/prompts/classify_inquiry.prompt b/samples/js-emailResponder/prompts/classify_inquiry.prompt new file mode 100644 index 000000000..bee83e7a6 --- /dev/null +++ b/samples/js-emailResponder/prompts/classify_inquiry.prompt @@ -0,0 +1,40 @@ +--- +model: vertexai/gemini-1.5-pro +input: + schema: + inquiry: string +output: + schema: + type: object + properties: + intent: + type: string + subintent: + type: string +--- +Classify the following customer inquiry into one of these intent/subintent pairs: +- Catalog/GeneralQuestion +- Catalog/ProductAvailability +- Product/GeneralQuestion +- Product/StockAvailability +- Product/PriceInquiry +- Order/GeneralQuestion +- Order/TrackingStatus +- Order/CancellationRequest +- Returns/ProcessInquiry +- Returns/RefundStatus +- Shipping/DeliveryTimeframe +- Shipping/CostInquiry +- Account/LoginIssue +- Account/UpdateInformation +- Payment/MethodInquiry +- Payment/TransactionIssue +- Warranty/CoverageInquiry +- Warranty/ClaimProcess +- Feedback/ProductReview +- Feedback/CustomerService +- Other/Other + +Customer inquiry: {{inquiry}} + +Classification: \ No newline at end of file diff --git a/samples/js-emailResponder/prompts/extract_info.prompt b/samples/js-emailResponder/prompts/extract_info.prompt new file mode 100644 index 000000000..32e819e2a --- /dev/null +++ b/samples/js-emailResponder/prompts/extract_info.prompt @@ -0,0 +1,47 @@ +--- +model: vertexai/gemini-1.5-pro +input: + schema: + inquiry: string + category: string +output: + schema: + type: object + properties: + productId: + type: string + orderId: + type: string + customerId: + type: string + issue: + type: string +--- + +### TASK +Extract key information from the following customer inquiry. + +Please provide the following information: +- Product ID (if mentioned) +- Order ID (if mentioned) +- Customer ID (if mentioned) +- Main issue or question + +Important: If any of the requested information is not available in the inquiry, provide an empty string for that field. Do NOT invent or hallucinate any information. + +### CUSTOMER INQUIRY CONTEXT + +This is the inquiry information. + +Category: {{category}} +Inquiry: {{inquiry}} + +### EXAMPLE OUTPUT +{ + "productId": "PROD123", + "orderId": "ORD456", + "customerId": "", + "issue": "When will my order be delivered?" +} + +Extracted Information: \ No newline at end of file diff --git a/samples/js-emailResponder/prompts/generate_draft.prompt b/samples/js-emailResponder/prompts/generate_draft.prompt new file mode 100644 index 000000000..2eb127307 --- /dev/null +++ b/samples/js-emailResponder/prompts/generate_draft.prompt @@ -0,0 +1,42 @@ +--- +model: vertexai/gemini-1.5-pro +input: + schema: + type: object + properties: + intent: + type: string + subintent: + type: string + inquiry: + type: string + context: + type: string +output: + schema: + type: object + properties: + draftResponse: + type: string +--- +Generate a draft response for the following customer inquiry: + +Intent: {{intent}} +Subintent: {{subintent}} +Customer Inquiry: {{inquiry}} +Context: {{context}} + +{% if escalate %} +This inquiry needs to be escalated to a human representative. +{% endif %} + +Please create a helpful and empathetic draft response addressing the customer's concerns. Include relevant details from the provided information based on the intent and subintent: + +1. For Catalog/GeneralQuestion: Provide an overview of the catalog or answer general questions about products. +2. For Product/GeneralQuestion: Answer general questions about the specific product, using the product information provided. +3. For Product/StockAvailability: Inform the customer about the stock status of the requested product. +4. For Other/Other: Politely inform the customer that their inquiry will be escalated to a specialist for further assistance. + +Ensure the tone is professional, friendly, and tailored to the customer's needs. + +Draft Response: \ No newline at end of file diff --git a/samples/js-emailResponder/prompts/handler_catalog_generalquestion.prompt b/samples/js-emailResponder/prompts/handler_catalog_generalquestion.prompt new file mode 100644 index 000000000..63a5f919d --- /dev/null +++ b/samples/js-emailResponder/prompts/handler_catalog_generalquestion.prompt @@ -0,0 +1,49 @@ +--- +model: vertexai/gemini-1.5-pro +input: + schema: + type: object + properties: + inquiry: + type: string + context: + type: string +output: + schema: + type: object + properties: + needsUserInput: + type: boolean + nextAction: + type: string + actionsTaken: + type: array + items: + type: string + data: + type: object + handlerCompleted: + type: boolean + summary: + type: string +--- +You are a customer service AI assistant handling a general question about a product catalog. Follow these steps: + +1. Analyze the customer's inquiry to identify the product or catalog category in question. +2. If the product or category is ambiguous or not specified, ask the customer to confirm before proceeding. +3. Use the available catalog information to answer the customer's question. +4. Provide a clear and concise answer to the customer's question. + +Execute as many steps as possible before requiring user input. If you complete all steps, set handlerCompleted to true. + +Customer Inquiry: {{inquiry}} +Context: {{context}} + +Respond with a JSON object containing: +- needsUserInput: boolean indicating if user input is required +- nextAction: string describing the next action needed from the user. Return DONE if no action. +- actionsTaken: array of strings describing the actions you've taken +- data: object containing any relevant data or answers +- handlerCompleted: boolean indicating if all steps have been completed + +Response: diff --git a/samples/js-emailResponder/prompts/handler_order_generalquestion.prompt b/samples/js-emailResponder/prompts/handler_order_generalquestion.prompt new file mode 100644 index 000000000..54293dba5 --- /dev/null +++ b/samples/js-emailResponder/prompts/handler_order_generalquestion.prompt @@ -0,0 +1,49 @@ +--- +model: vertexai/gemini-1.5-pro +input: + schema: + type: object + properties: + inquiry: + type: string + context: + type: string +output: + schema: + type: object + properties: + needsUserInput: + type: boolean + nextAction: + type: string + actionsTaken: + type: array + items: + type: string + data: + type: object + handlerCompleted: + type: boolean + summary: + type: string +--- +You are a customer service AI assistant handling a general question about a specific order. Follow these steps: + +1. Analyze the customer's inquiry to identify the order in question. +2. If the order is ambiguous or not specified, ask the customer to confirm before proceeding. +3. Use the available information about the order to answer the customer's question. +4. Provide a clear and concise answer to the customer's question. + +Execute as many steps as possible before requiring user input. If you complete all steps, set handlerCompleted to true. + +Customer Inquiry: {{inquiry}} +Context: {{context}} + +Respond with a JSON object containing: +- needsUserInput: boolean indicating if user input is required +- nextAction: string describing the next action needed from the user. Return DONE if no action. +- actionsTaken: array of strings describing the actions you've taken +- data: object containing any relevant data or answers +- handlerCompleted: boolean indicating if all steps have been completed + +Response: \ No newline at end of file diff --git a/samples/js-emailResponder/prompts/handler_product_generalquestion.prompt b/samples/js-emailResponder/prompts/handler_product_generalquestion.prompt new file mode 100644 index 000000000..d27b1e9d2 --- /dev/null +++ b/samples/js-emailResponder/prompts/handler_product_generalquestion.prompt @@ -0,0 +1,49 @@ +--- +model: vertexai/gemini-1.5-pro +input: + schema: + type: object + properties: + inquiry: + type: string + context: + type: string +output: + schema: + type: object + properties: + needsUserInput: + type: boolean + nextAction: + type: string + actionsTaken: + type: array + items: + type: string + data: + type: object + handlerCompleted: + type: boolean + summary: + type: string +--- +You are a customer service AI assistant handling a general question about a specific product. Follow these steps: + +1. Analyze the customer's inquiry to identify the product in question. +2. If the product is ambiguous or not specified, ask the customer to confirm before proceeding. +3. Use the available information about the product to answer the customer's question. +4. Provide a clear and concise answer to the customer's question. + +Execute as many steps as possible before requiring user input. If you complete all steps, set handlerCompleted to true. + +Customer Inquiry: {{inquiry}} +Context: {{context}} + +Respond with a JSON object containing: +- needsUserInput: boolean indicating if user input is required +- nextAction: string describing the next action needed from the user. Return DONE if no action. +- actionsTaken: array of strings describing the actions you've taken +- data: object containing any relevant data or answers +- handlerCompleted: boolean indicating if all steps have been completed + +Response: \ No newline at end of file diff --git a/samples/js-emailResponder/prompts/handler_product_stockavailability.prompt b/samples/js-emailResponder/prompts/handler_product_stockavailability.prompt new file mode 100644 index 000000000..f11b5212c --- /dev/null +++ b/samples/js-emailResponder/prompts/handler_product_stockavailability.prompt @@ -0,0 +1,50 @@ +--- +model: vertexai/gemini-1.5-pro +input: + schema: + type: object + properties: + inquiry: + type: string + context: + type: string +output: + schema: + type: object + properties: + needsUserInput: + type: boolean + nextAction: + type: string + actionsTaken: + type: array + items: + type: string + data: + type: object + handlerCompleted: + type: boolean + summary: + type: string +--- +You are a customer service AI assistant handling a stock availability question about a specific product. Follow these steps: + +1. Analyze the customer's inquiry to identify the product in question. +2. If the product is ambiguous or not specified, ask the customer to confirm before proceeding. +3. Check the available information about the product's stock status. +4. Provide a clear and concise answer about the product's availability. +5. If the product is out of stock, provide information on when it might be restocked, if available. + +Execute as many steps as possible before requiring user input. If you complete all steps, set handlerCompleted to true. + +Customer Inquiry: {{inquiry}} +Context: {{context}} + +Respond with a JSON object containing: +- needsUserInput: boolean indicating if user input is required +- nextAction: string describing the next action needed from the user. Return DONE if no action. +- actionsTaken: array of strings describing the actions you've taken +- data: object containing any relevant data or answers about stock availability +- handlerCompleted: boolean indicating if all steps have been completed + +Response: diff --git a/samples/js-emailResponder/src/db.ts b/samples/js-emailResponder/src/db.ts new file mode 100644 index 000000000..504b8d1e4 --- /dev/null +++ b/samples/js-emailResponder/src/db.ts @@ -0,0 +1,345 @@ +/** + * Copyright 2024 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. + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// Product-related functions +/** + * Retrieves a product by its ID + * @param id The ID of the product + * @returns The product or null if not found + */ +export async function getProductById(id: number) { + try { + return await prisma.product.findUnique({ + where: { id }, + }); + } catch (error) { + console.error('Error fetching product:', error); + throw error; + } +} + +/** + * Lists all products + * @returns An array of all products + */ +export async function listProducts() { + try { + return await prisma.product.findMany(); + } catch (error) { + console.error('Error listing products:', error); + throw error; + } +} + +// Order-related functions +/** + * Retrieves an order by its ID + * @param id The ID of the order + * @returns The order with customer and product details, or null if not found + */ +export async function getOrderById(id: number) { + try { + return await prisma.order.findUnique({ + where: { id }, + include: { + customer: true, + orderItems: { + include: { + product: true, + }, + }, + }, + }); + } catch (error) { + console.error('Error fetching order:', error); + throw error; + } +} + +/** + * Lists all orders + * @returns An array of all orders with customer and product details + */ +export async function listOrders() { + try { + return await prisma.order.findMany({ + include: { + customer: true, + orderItems: { + include: { + product: true, + }, + }, + }, + }); + } catch (error) { + console.error('Error listing orders:', error); + throw error; + } +} + +/** + * Retrieves recent orders for a customer + * @param customerId The ID of the customer + * @param limit The maximum number of orders to retrieve (default: 5) + * @returns An array of recent orders with product details + */ +export async function getRecentOrders(customerId: number, limit: number = 5) { + try { + return await prisma.order.findMany({ + where: { + customerId: customerId, + }, + orderBy: { + orderDate: 'desc', + }, + take: limit, + include: { + orderItems: { + include: { + product: true, + }, + }, + }, + }); + } catch (error) { + console.error('Error fetching recent orders:', error); + throw error; + } +} + +/** + * Retrieves orders by customer email + * @param email The email of the customer + * @returns An array of orders or null if the customer is not found + */ +export async function getOrdersByCustomerEmail(email: string) { + try { + const customer = await prisma.customer.findUnique({ + where: { + email: email, + }, + include: { + orders: { + include: { + orderItems: { + include: { + product: true, + }, + }, + }, + }, + }, + }); + return customer ? customer.orders : null; + } catch (error) { + console.error('Error fetching orders by customer email:', error); + throw error; + } +} + +/** + * Retrieves recent orders by customer email + * @param email The email of the customer + * @param limit The maximum number of orders to retrieve (default: 5) + * @returns An array of recent orders with product details + */ +export async function getRecentOrdersByEmail(email: string, limit: number = 5) { + try { + return await prisma.order.findMany({ + where: { + customer: { + email: email, + }, + }, + orderBy: { + orderDate: 'desc', + }, + take: limit, + include: { + orderItems: { + include: { + product: true, + }, + }, + }, + }); + } catch (error) { + console.error('Error fetching recent orders by email:', error); + throw error; + } +} + +// Customer-related functions +/** + * Retrieves a customer by their ID + * @param id The ID of the customer + * @returns The customer with their orders, or null if not found + */ +export async function getCustomerById(id: number) { + try { + return await prisma.customer.findUnique({ + where: { id }, + include: { + orders: true, + }, + }); + } catch (error) { + console.error('Error fetching customer:', error); + throw error; + } +} + +/** + * Lists all customers + * @returns An array of all customers with their orders + */ +export async function listCustomers() { + try { + return await prisma.customer.findMany({ + include: { + orders: true, + }, + }); + } catch (error) { + console.error('Error listing customers:', error); + throw error; + } +} + +/** + * Retrieves a customer by their email + * @param email The email of the customer + * @returns The customer with their orders and order details, or null if not found + */ +export async function getCustomerByEmail(email: string) { + try { + return await prisma.customer.findUnique({ + where: { + email: email, + }, + include: { + orders: { + include: { + orderItems: { + include: { + product: true, + }, + }, + }, + }, + }, + }); + } catch (error) { + console.error('Error fetching customer by email:', error); + throw error; + } +} + +// Escalation-related functions +/** + * Creates a new escalation + * @param customerId The ID of the customer + * @param subject The subject of the escalation + * @param description The description of the escalation + * @param threadId The thread ID associated with the escalation + * @returns The created escalation + */ +export async function createEscalation( + customerId: number, + subject: string, + description: string, + threadId: string +) { + try { + return await prisma.escalation.create({ + data: { + customerId, + subject, + description, + threadId, + }, + }); + } catch (error) { + console.error('Error creating escalation:', error); + throw error; + } +} + +/** + * Retrieves an escalation by its ID + * @param id The ID of the escalation + * @returns The escalation with customer details, or null if not found + */ +export async function getEscalationById(id: number) { + try { + return await prisma.escalation.findUnique({ + where: { id }, + include: { + customer: true, + }, + }); + } catch (error) { + console.error('Error fetching escalation:', error); + throw error; + } +} + +/** + * Updates the status of an escalation + * @param id The ID of the escalation + * @param status The new status of the escalation + * @returns The updated escalation + */ +export async function updateEscalationStatus(id: number, status: string) { + try { + return await prisma.escalation.update({ + where: { id }, + data: { status }, + }); + } catch (error) { + console.error('Error updating escalation status:', error); + throw error; + } +} + +/** + * Lists all escalations + * @returns An array of all escalations with customer details + */ +export async function listEscalations() { + try { + return await prisma.escalation.findMany({ + include: { + customer: true, + }, + }); + } catch (error) { + console.error('Error listing escalations:', error); + throw error; + } +} + +/** + * Disconnects the Prisma client + */ +export async function disconnectPrisma() { + await prisma.$disconnect(); +} diff --git a/samples/js-emailResponder/src/handlers.ts b/samples/js-emailResponder/src/handlers.ts new file mode 100644 index 000000000..69044c039 --- /dev/null +++ b/samples/js-emailResponder/src/handlers.ts @@ -0,0 +1,91 @@ +/** + * Copyright 2024 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. + */ + +import * as fs from 'fs'; +import { z } from 'genkit'; +import * as path from 'path'; +import { ai } from '.'; + +// Handler concept: A handler is responsible for processing specific intents and subintents in the chatbot. +// It takes user input, processes it, and generates an appropriate response or action. + +// Define the structure of a handler's result +const HandlerResult = z.object({ + needsUserInput: z.boolean(), + nextAction: z.string().optional(), + actionsTaken: z.array(z.string()), + data: z.record(z.unknown()), + handlerCompleted: z.boolean(), +}); + +// Define the input structure for a handler +type HandlerInput = { + intent: string; + subintent: string; + inquiry: string; + context: Record; +}; + +// Main function to execute a handler based on the given input +export async function executeHandler( + input: HandlerInput +): Promise> { + // Get the appropriate prompt for the handler based on intent and subintent + const handlerPrompt = getHandlerPrompt(input.intent, input.subintent); + + // Generate a response using the handler's prompt + const handlerResult = await handlerPrompt({ + input: { + inquiry: input.inquiry, + context: JSON.stringify(input.context, null, 2), + }, + }); + + // Process the output from the handler + const output = handlerResult.output; + + // Return the structured result of the handler's execution + return { + needsUserInput: output.needsUserInput || false, + nextAction: output.nextAction, + actionsTaken: output.actionsTaken || [], + data: output.data || {}, + handlerCompleted: output.handlerCompleted || false, + }; +} + +// Function to retrieve the appropriate prompt for a handler +function getHandlerPrompt(intent: string, subintent: string) { + // Construct the prompt key based on intent and subintent + const promptKey = `handler_${intent.toLowerCase()}_${subintent.toLowerCase()}`; + + // Determine the file path for the handler's prompt + const promptPath = path.join( + __dirname, + '..', + 'prompts', + `${promptKey}.prompt` + ); + + // Check if the prompt file exists and return it, or throw an error if not found + if (fs.existsSync(promptPath)) { + return ai.prompt(promptKey); + } else { + throw new Error( + `NoHandlerPromptError: No handler prompt found for intent '${intent}' and subintent '${subintent}'` + ); + } +} diff --git a/samples/js-emailResponder/src/index.ts b/samples/js-emailResponder/src/index.ts new file mode 100644 index 000000000..0810fa8b1 --- /dev/null +++ b/samples/js-emailResponder/src/index.ts @@ -0,0 +1,350 @@ +/** + * Copyright 2024 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. + */ + +import genkitEval, { GenkitMetric } from '@genkit-ai/evaluator'; +import vertexAI, { + gemini10Pro, + gemini15Pro, + textEmbedding004, +} from '@genkit-ai/vertexai'; +import { genkit, z } from 'genkit'; +import { + createEscalation, + getCustomerByEmail, + getOrderById, + getProductById, + getRecentOrdersByEmail, + listProducts, +} from './db'; +import { executeHandler } from './handlers'; + +// Configure Genkit with necessary plugins +export const ai = genkit({ + plugins: [ + vertexAI({ + projectId: process.env.PROJECT_ID, + location: process.env.LOCATION || 'us-central1', + }), + genkitEval({ + judge: gemini15Pro, + metrics: [GenkitMetric.FAITHFULNESS, GenkitMetric.ANSWER_RELEVANCY], + embedder: textEmbedding004, + }), + ], + model: gemini10Pro, +}); + +// Define prompts +const classifyInquiryPrompt = ai.prompt('classify_inquiry'); +const generateDraftPrompt = ai.prompt('generate_draft'); +const extractInfoPrompt = ai.prompt('extract_info'); + +export const classifyInquiryFlow = ai.defineFlow( + { + name: 'classifyInquiryFlow', + inputSchema: z.object({ + inquiry: z.string(), + }), + outputSchema: z.object({ + intent: z.string(), + subintent: z.string(), + }), + }, + async (input) => { + try { + console.log('Classifying inquiry:', input.inquiry); + const classificationResult = await classifyInquiryPrompt({ + inquiry: input.inquiry, + }); + return classificationResult.output; + } catch (error) { + console.error('Error in classifyInquiryFlow:', error); + throw error; + } + } +); + +export const customerServiceFlow = ai.defineFlow( + { + name: 'customerServiceFlow', + inputSchema: z.object({ + from: z.string(), + to: z.string(), + subject: z.string(), + body: z.string(), + sentAt: z.string(), // Changed from timestamp to sentAt + threadHistory: z.array( + z.object({ + from: z.string(), + to: z.string(), + body: z.string(), + sentAt: z.string(), // Changed from timestamp to sentAt + }) + ), + }), + outputSchema: z.object({ + intent: z.string(), + subintent: z.string(), + response: z.string(), + needsUserInput: z.boolean(), + nextAction: z.string().optional(), + }), + }, + async (input) => { + console.log('Starting customerServiceFlow with input:', { + from: input.from, + to: input.to, + subject: input.subject, + body: input.body, + threadHistoryLength: input.threadHistory.length, + }); + + // Step 1: Classify the inquiry + console.log('Step 1: Classifying inquiry...'); + const classificationResult = await classifyInquiryFlow({ + inquiry: input.body, + }); + console.log('Classification result:', classificationResult); + const { intent, subintent } = classificationResult; + + // Step 2: Augment data + console.log('Step 2: Augmenting data...'); + const augmentedData = await augmentInfo({ + intent, + customerInquiry: input.body, + email: input.from, + }); + console.log('Augmented data:', augmentedData); + + // Step 3: Execute Handler + console.log('Step 3: Executing handler...'); + let handlerResult; + try { + handlerResult = await executeHandlerFlow({ + intent, + subintent, + inquiry: input.body, + context: { + ...augmentedData.responseData, + subject: input.subject, + threadHistory: input.threadHistory, + }, + }); + console.log('Handler result:', handlerResult); + } catch (error) { + console.error('Error executing handler:', error); + // Escalate if no handler + if ( + error instanceof Error && + error.message.startsWith('NoHandlerPromptError') + ) { + console.log('No handler found, escalating to human...'); + const escalationResult = await escalateToHuman( + input.body, + input.from, + 'No handler found' + ); + console.log('Escalation result:', escalationResult); + return { + intent, + subintent, + response: escalationResult.message, + needsUserInput: false, + nextAction: 'wait_for_human', + escalated: true, + escalationReason: 'No handler found', + }; + } else { + throw error; // Re-throw other errors + } + } + + // Step 4: Generate response + console.log('Step 4: Generating response...'); + const responseResult = await generateDraftFlow({ + intent, + subintent, + inquiry: input.body, + context: { + ...augmentedData.responseData, + subject: input.subject, + threadHistory: input.threadHistory, + }, + handlerResult: handlerResult.data, + }); + console.log('Generated response:', responseResult); + + const result = { + intent, + subintent, + response: responseResult.draftResponse, + needsUserInput: handlerResult.needsUserInput ?? false, + nextAction: handlerResult.nextAction, + escalated: false, + }; + console.log('Final result:', result); + return result; + } +); + +async function escalateToHuman(inquiry: string, email: string, reason: string) { + const customer = await getCustomerByEmail(email); + if (!customer) { + throw new Error('Customer not found'); + } + + const escalation = await createEscalation( + customer.id, + 'Customer Inquiry Escalation', + `Inquiry: ${inquiry}\n\nReason for escalation: ${reason}`, + inquiry + ); + + return { + message: + "Your inquiry has been escalated to our customer service team. We'll get back to you as soon as possible.", + escalationId: escalation.id, + }; +} + +export const augmentInfo = ai.defineFlow( + { + name: 'augmentInfoFlow', + inputSchema: z.object({ + intent: z.string(), + customerInquiry: z.string(), + email: z.string(), + }), + outputSchema: z.object({ + responseData: z.record(z.unknown()), + }), + }, + async (input) => { + let responseData = {}; + switch (input.intent) { + case 'Catalog': + const products = await listProducts(); + responseData = { catalog: products }; + break; + case 'Product': + const productInfo = await extractInfoFlow({ + inquiry: input.customerInquiry, + }); + if (productInfo.productId) { + const product = await getProductById(productInfo.productId); + responseData = { product }; + } else { + const products = await listProducts(); + responseData = { products }; + } + break; + case 'Order': + const orderInfo = await extractInfoFlow({ + inquiry: input.customerInquiry, + }); + console.log(orderInfo); + console.log('Extracted order info:', orderInfo); + if (orderInfo.orderId) { + const order = await getOrderById(orderInfo.orderId); + console.log('Retrieved order:', order); + responseData = { order }; + } else { + const recentOrders = await getRecentOrdersByEmail(input.email); + responseData = { recentOrders }; + } + break; + case 'Other': + const customer = await getCustomerByEmail(input.email); + responseData = { customer }; + break; + } + return { responseData }; + } +); + +export const extractInfoFlow = ai.defineFlow( + { + name: 'extractInfoFlow', + inputSchema: z.object({ + inquiry: z.string(), + }), + outputSchema: z.object({ + productId: z.number(), + orderId: z.number(), + customerId: z.number(), + issue: z.string(), + }), + }, + async (input) => { + const extractionResult = await extractInfoPrompt({ + inquiry: input.inquiry, + category: 'Customer Service', + }); + const output = extractionResult.output; + return { + productId: output.productId ? parseInt(output.productId, 10) : 0, + orderId: output.orderId ? parseInt(output.orderId, 10) : 0, + customerId: output.customerId ? parseInt(output.customerId, 10) : 0, + issue: output.issue || '', + }; + } +); + +export const executeHandlerFlow = ai.defineFlow( + { + name: 'executeHandlerFlow', + inputSchema: z.object({ + intent: z.string(), + subintent: z.string(), + inquiry: z.string(), + context: z.record(z.unknown()), + }), + outputSchema: z.object({ + data: z.unknown(), + needsUserInput: z.boolean().optional(), + nextAction: z.string().optional(), + }), + }, + async (input) => { + return executeHandler(input); + } +); + +export const generateDraftFlow = ai.defineFlow( + { + name: 'generateDraftFlow', + inputSchema: z.object({ + intent: z.string(), + subintent: z.string(), + inquiry: z.string(), + context: z.record(z.unknown()), + handlerResult: z.unknown(), + }), + outputSchema: z.object({ + draftResponse: z.string(), + }), + }, + async (input) => { + const responseResult = await generateDraftPrompt({ + intent: input.intent, + subintent: input.subintent, + inquiry: input.inquiry, + context: JSON.stringify(input.context, null, 2), + handlerResult: input.handlerResult, + }); + return { draftResponse: responseResult.output.draftResponse }; + } +); diff --git a/samples/js-emailResponder/src/prisma/schema.prisma b/samples/js-emailResponder/src/prisma/schema.prisma new file mode 100644 index 000000000..90c4e3539 --- /dev/null +++ b/samples/js-emailResponder/src/prisma/schema.prisma @@ -0,0 +1,59 @@ +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +generator client { + provider = "prisma-client-js" +} + +model Customer { + id Int @id @default(autoincrement()) + name String + email String @unique + orders Order[] + escalations Escalation[] +} + +model Product { + id Int @id @default(autoincrement()) + name String + description String + price Float + sku String @unique + stockLevel Int + orderItems OrderItem[] +} + +model Order { + id Int @id @default(autoincrement()) + customer Customer @relation(fields: [customerId], references: [id]) + customerId Int + orderDate DateTime @default(now()) + status String @default("PENDING") + trackingNumber String? + orderItems OrderItem[] +} + +model OrderItem { + id Int @id @default(autoincrement()) + order Order @relation(fields: [orderId], references: [id]) + orderId Int + product Product @relation(fields: [productId], references: [id]) + productId Int + quantity Int + + @@unique([orderId, productId]) +} + +model Escalation { + id Int @id @default(autoincrement()) + customer Customer @relation(fields: [customerId], references: [id]) + customerId Int + subject String + description String + status String @default("OPEN") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + threadId String +} diff --git a/samples/js-emailResponder/src/prisma/seed.ts b/samples/js-emailResponder/src/prisma/seed.ts new file mode 100644 index 000000000..ff81571e3 --- /dev/null +++ b/samples/js-emailResponder/src/prisma/seed.ts @@ -0,0 +1,170 @@ +/** + * Copyright 2024 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. + */ + +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + // Seed Products with hardcoded values + const products = []; + const productData = [ + { + id: 1, + name: 'Classic Blue T-Shirt', + description: 'Comfortable cotton t-shirt in classic blue', + stockLevel: 50, + price: 19.99, + sku: 'BLU-TSHIRT-M', + }, + { + id: 2, + name: 'Running Shoes', + description: 'Lightweight running shoes with cushioned sole', + stockLevel: 25, + price: 89.99, + sku: 'RUN-SHOE-42', + }, + { + id: 3, + name: 'Denim Jeans', + description: 'Classic fit denim jeans in dark wash', + stockLevel: 75, + price: 49.99, + sku: 'DEN-JEAN-32', + }, + { + id: 4, + name: 'Leather Wallet', + description: 'Genuine leather bifold wallet', + stockLevel: 100, + price: 29.99, + sku: 'LEA-WALL-01', + }, + { + id: 5, + name: 'Wireless Headphones', + description: 'Noise-cancelling wireless headphones', + stockLevel: 30, + price: 149.99, + sku: 'WIR-HEAD-BK', + }, + ]; + + for (const data of productData) { + products.push( + await prisma.product.create({ + data, + }) + ); + } + + // Seed Customers with hardcoded values + const customers = []; + const customerData = [ + { + id: 1, + name: 'John Doe', + email: 'john.doe@example.com', + }, + { + id: 2, + name: 'Jane Smith', + email: 'jane.smith@example.com', + }, + { + id: 3, + name: 'Bob Wilson', + email: 'bob.wilson@example.com', + }, + ]; + + for (const data of customerData) { + customers.push( + await prisma.customer.create({ + data, + }) + ); + } + + // Seed Orders with hardcoded values + const orderData = [ + { + customerId: customers[0].id, + status: 'DELIVERED', + trackingNumber: 'TRACK123456', + orderItems: { + create: [ + { + productId: products[0].id, + quantity: 2, + }, + { + productId: products[1].id, + quantity: 1, + }, + ], + }, + }, + { + customerId: customers[1].id, + status: 'PROCESSING', + trackingNumber: 'TRACK789012', + orderItems: { + create: [ + { + productId: products[2].id, + quantity: 1, + }, + ], + }, + }, + { + customerId: customers[2].id, + status: 'PENDING', + trackingNumber: 'TRACK345678', + orderItems: { + create: [ + { + productId: products[3].id, + quantity: 1, + }, + { + productId: products[4].id, + quantity: 1, + }, + ], + }, + }, + ]; + + for (const data of orderData) { + await prisma.order.create({ + data, + }); + } + + console.log('Database has been seeded with hardcoded values.'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/samples/js-emailResponder/tsconfig.json b/samples/js-emailResponder/tsconfig.json new file mode 100644 index 000000000..b73ccd04d --- /dev/null +++ b/samples/js-emailResponder/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noImplicitReturns": true, + "noUnusedLocals": false, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017", + "skipLibCheck": true, + "esModuleInterop": true + }, + "compileOnSave": true, + "include": ["src"] +}