diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..648168d3 Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example index 8b6150fc..bf494d97 100644 --- a/.env.example +++ b/.env.example @@ -12,10 +12,14 @@ SERVER='myserver.website.com' # Service API Keys OPENAI_API_KEY= -DEEPGRAM_API_KEY= -# Deepgram voice model, see more options here: https://developers.deepgram.com/docs/tts-models -VOICE_MODEL=aura-asteria-en +# Supported TTS Providers and Voices: +# all of these: https://www.twilio.com/docs/voice/twiml/say/text-speech#available-voices-and-languages +# plus these... +# Google: en-US-Journey-D, en-US-Journey-F, en-US-Journey-O, en-IN-Journey-D, en-IN-Journey-F, en-GB-Journey-D, en-GB-Journey-F, de-DE-Journey-D, de-DE-Journey-F +# Amazon: Amy-Generative, Matthew-Generative, Ruth-Generative +TTS_PROVIDER='amazon' +TTS_VOICE='Danielle-Neural' # Call Recording # Important: Legal implications of call recording diff --git a/.eslintrc.js b/.eslintrc.js index ddcd4dae..c3c9d464 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,47 +1,33 @@ module.exports = { - 'env': { - 'browser': true, - 'commonjs': true, - 'es2021': true + env: { + browser: true, + commonjs: true, + es2021: true, }, - 'extends': 'eslint:recommended', - 'overrides': [ + extends: "eslint:recommended", + overrides: [ { - 'env': { - 'node': true + env: { + node: true, }, - 'files': [ - '.eslintrc.{js,cjs}' - ], - 'parserOptions': { - 'sourceType': 'script' - } - } + files: [".eslintrc.{js,cjs}"], + parserOptions: { + sourceType: "script", + }, + }, ], - 'globals' : { - 'expect': 'writeable', - 'test': 'writeable', - 'process': 'readable' + globals: { + expect: "writeable", + test: "writeable", + process: "readable", + }, + parserOptions: { + ecmaVersion: "latest", }, - 'parserOptions': { - 'ecmaVersion': 'latest' + rules: { + indent: "off", // Turns off indent enforcement + "linebreak-style": "off", // Turns off linebreak enforcement + quotes: "off", // Turns off quote enforcement + semi: "off", // Turns off semicolon enforcement }, - 'rules': { - 'indent': [ - 'error', - 2 - ], - 'linebreak-style': [ - 'error', - 'unix' - ], - 'quotes': [ - 'error', - 'single' - ], - 'semi': [ - 'error', - 'always' - ] - } }; diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml new file mode 100644 index 00000000..b0c246ed --- /dev/null +++ b/.github/workflows/fly-deploy.yml @@ -0,0 +1,18 @@ +# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ + +name: Fly Deploy +on: + push: + branches: + - main +jobs: + deploy: + name: Deploy app + runs-on: ubuntu-latest + concurrency: deploy-group # optional: ensure only one action runs at a time + steps: + - uses: actions/checkout@v4 + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: flyctl deploy --remote-only + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.gitignore b/.gitignore index fdf76555..962f8473 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ # Edit at https://www.toptal.com/developers/gitignore?templates=node ### Node ### + +#personalization +data/personalization.js + # Logs logs *.log diff --git a/README.md b/README.md index 36c558d4..8aa73db1 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,13 @@ Wouldn't it be neat if you could build an app that allowed you to chat with Chat Twilio gives you a superpower called [Media Streams](https://twilio.com/media-streams). Media Streams provides a Websocket connection to both sides of a phone call. You can get audio streamed to you, process it, and send audio back. This app serves as a demo exploring two services: -- [Deepgram](https://deepgram.com/) for Speech to Text and Text to Speech + - [OpenAI](https://openai.com) for GPT prompt completion These service combine to create a voice application that is remarkably better at transcribing, understanding, and speaking than traditional IVR systems. Features: + - 🏁 Returns responses with low latency, typically 1 second by utilizing streaming. - ❗️ Allows the user to interrupt the GPT assistant and ask a different question. - πŸ“” Maintains chat history with GPT. @@ -19,21 +20,25 @@ Features: ## Setting up for Development ### Prerequisites + Sign up for the following services and get an API key for each: -- [Deepgram](https://console.deepgram.com/signup) + - [OpenAI](https://platform.openai.com/signup) If you're hosting the app locally, we also recommend using a tunneling service like [ngrok](https://ngrok.com) so that Twilio can forward audio to your app. ### 1. Start Ngrok + Start an [ngrok](https://ngrok.com) tunnel for port `3000`: ```bash ngrok http 3000 ``` + Ngrok will give you a unique URL, like `abc123.ngrok.io`. Copy the URL without http:// or https://. You'll need this URL in the next step. ### 2. Configure Environment Variables + Copy `.env.example` to `.env` and configure the following environment variables: ```bash @@ -43,7 +48,6 @@ SERVER="yourserverdomain.com" # Service API Keys OPENAI_API_KEY="sk-XXXXXX" -DEEPGRAM_API_KEY="YOUR-DEEPGRAM-API-KEY" # Configure your Twilio credentials if you want # to make test calls using '$ npm test'. @@ -54,6 +58,7 @@ TO_NUMBER='+13334445555' ``` ### 3. Install Dependencies with NPM + Install the necessary packages: ```bash @@ -61,10 +66,13 @@ npm install ``` ### 4. Start Your Server in Development Mode + Run the following command: + ```bash npm run dev ``` + This will start your app using `nodemon` so that any changes to your code automatically refreshes and restarts the server. ### 5. Configure an Incoming Phone Number @@ -76,14 +84,16 @@ You can also use the Twilio CLI: ```bash twilio phone-numbers:update +1[your-twilio-number] --voice-url=https://your-server.ngrok.io/incoming ``` + This configuration tells Twilio to send incoming call audio to your app when someone calls your number. The app responds to the incoming call webhook with a [Stream](https://www.twilio.com/docs/voice/twiml/stream) TwiML verb that will connect an audio media stream to your websocket server. ## Application Workflow + CallGPT coordinates the data flow between multiple different services including Deepgram, OpenAI, and Twilio Media Streams: ![Call GPT Flow](https://github.com/twilio-labs/call-gpt/assets/1418949/0b7fcc0b-d5e5-4527-bc4c-2ffb8931139c) - ## Modifying the ChatGPT Context & Prompt + Within `gpt-service.js` you'll find the settings for the GPT's initial context and prompt. For example: ```javascript @@ -92,7 +102,9 @@ this.userContext = [ { "role": "assistant", "content": "Hello! I understand you're looking for a pair of AirPods, is that correct?" }, ], ``` + ### About the `system` Attribute + The `system` attribute is background information for the GPT. As you build your use-case, play around with modifying the context. A good starting point would be to imagine training a new employee on their first day and giving them the basics of how to help a customer. There are some context prompts that will likely be helpful to include by default. For example: @@ -109,17 +121,21 @@ These context items help shape a GPT so that it will act more naturally in a pho The `β€’` symbol context in particular is helpful for the app to be able to break sentences into natural chunks. This speeds up text-to-speech processing so that users hear audio faster. ### About the `content` Attribute + This attribute is your default conversations starter for the GPT. However, you could consider making it more complex and customized based on personalized user data. In this case, our bot will start off by saying, "Hello! I understand you're looking for a pair of AirPods, is that correct?" ## Using Function Calls with GPT + You can use function calls to interact with external APIs and data sources. For example, your GPT could check live inventory, check an item's price, or place an order. ### How Function Calling Works + Function calling is handled within the `gpt-service.js` file in the following sequence: 1. `gpt-service` loads `function-manifest.js` and requires (imports) all functions defined there from the `functions` directory. Our app will call these functions later when GPT gives us a function name and parameters. + ```javascript tools.forEach((tool) => { const functionName = tool.function.name; @@ -131,35 +147,42 @@ tools.forEach((tool) => { ```javascript const stream = await this.openai.chat.completions.create({ - model: 'gpt-4', + model: "gpt-4", messages: this.userContext, tools, // <-- function-manifest definition stream: true, }); ``` -3. When the GPT responds, it will send us a stream of chunks for the text completion. The GPT will tell us whether each text chunk is something to say to the user, or if it's a tool call that our app needs to execute. This is indicated by the `deltas.tool_calls` key: + +3. When the GPT responds, it will send us a stream of chunks for the text completion. The GPT will tell us whether each text chunk is something to say to the user, or if it's a tool call that our app needs to execute. This is indicated by the `deltas.tool_calls` key: + ```javascript if (deltas.tool_calls) { // handle function calling } ``` + 4. Once we have gathered all of the stream chunks about the tool call, our application can run the actual function code that we imported during the first step. The function name and parameters are provided by GPT: + ```javascript const functionToCall = availableFunctions[functionName]; const functionResponse = functionToCall(functionArgs); ``` + 5. As the final step, we add the function response data into the conversation context like this: ```javascript this.userContext.push({ - role: 'function', + role: "function", name: functionName, content: functionResponse, }); ``` + We then ask the GPT to generate another completion including what it knows from the function call. This allows the GPT to respond to the user with details gathered from the external data source. ### Adding Custom Function Calls + You can have your GPT call external data sources by adding functions to the `/functions` directory. Follow these steps: 1. Create a function (e.g. `checkInventory.js` in `/functions`) @@ -200,19 +223,23 @@ Example function manifest entry: }, } ``` + #### Using `say` in the Function Manifest + The `say` key in the function manifest allows you to define a sentence for the app to speak to the user before calling a function. For example, if a function will take a long time to call you might say "Give me a few moments to look that up for you..." ### Receiving Function Arguments + When ChatGPT calls a function, it will provide an object with multiple attributes as a single argument. The parameters included in the object are based on the definition in your `function-manifest.js` file. In the `checkInventory` example above, `model` is a required argument, so the data passed to the function will be a single object like this: ```javascript { - model: "airpods pro" + model: "airpods pro"; } ``` + For our `placeOrder` function, the arguments passed will look like this: ```javascript @@ -221,57 +248,28 @@ For our `placeOrder` function, the arguments passed will look like this: quantity: 10 } ``` + ### Returning Arguments to GPT -Your function should always return a value: GPT will get confused when the function returns nothing, and may continue trying to call the function expecting an answer. If your function doesn't have any data to return to the GPT, you should still return a response with an instruction like "Tell the user that their request was processed successfully." This prevents the GPT from calling the function repeatedly and wasting tokens. + +Your function should always return a value: GPT will get confused when the function returns nothing, and may continue trying to call the function expecting an answer. If your function doesn't have any data to return to the GPT, you should still return a response with an instruction like "Tell the user that their request was processed successfully." This prevents the GPT from calling the function repeatedly and wasting tokens. Any data that you return to the GPT should match the expected format listed in the `returns` key of `function-manifest.js`. ## Utility Scripts for Placing Calls + The `scripts` directory contains two files that allow you to place test calls: + - `npm run inbound` will place an automated call from a Twilio number to your app and speak a script. You can adjust this to your use-case, e.g. as an automated test. - `npm run outbound` will place an outbound call that connects to your app. This can be useful if you want the app to call your phone so that you can manually test it. -## Using Eleven Labs for Text to Speech -Replace the Deepgram API call and array transformation in tts-service.js with the following call to Eleven Labs. Note that sometimes Eleven Labs will hit a rate limit (especially on the free trial) and return 400 errors with no audio (or a clicking sound). - -``` -try { - const response = await fetch( - `https://api.elevenlabs.io/v1/text-to-speech/21m00Tcm4TlvDq8ikWAM/stream?output_format=ulaw_8000&optimize_streaming_latency=3`, - { - method: 'POST', - headers: { - 'xi-api-key': process.env.XI_API_KEY, - 'Content-Type': 'application/json', - accept: 'audio/wav', - }, - body: JSON.stringify({ - model_id: process.env.XI_MODEL_ID, - text: partialResponse, - }), - } - ); - - if (response.status === 200) { - const audioArrayBuffer = await response.arrayBuffer(); - this.emit('speech', partialResponseIndex, Buffer.from(audioArrayBuffer).toString('base64'), partialResponse, interactionCount); - } else { - console.log('Eleven Labs Error:'); - console.log(response); - } -} catch (err) { - console.error('Error occurred in XI LabsTextToSpeech service'); - console.error(err); -} -``` - - ## Testing with Jest + Repeatedly calling the app can be a time consuming way to test your tool function calls. This project contains example unit tests that can help you test your functions without relying on the GPT to call them. Simple example tests are available in the `/test` directory. To run them, simply run `npm run test`. ## Deploy via Fly.io + Fly.io is a hosting service similar to Heroku that simplifies the deployment process. Given Twilio Media Streams are sent and received from us-east-1, it's recommended to choose Fly's Ashburn, VA (IAD) region. > Deploying to Fly.io is not required to try the app, but can be helpful if your home internet speed is variable. @@ -279,6 +277,7 @@ Fly.io is a hosting service similar to Heroku that simplifies the deployment pro Modify the app name `fly.toml` to be a unique value (this must be globally unique). Deploy the app using the Fly.io CLI: + ```bash fly launch @@ -286,6 +285,7 @@ fly deploy ``` Import your secrets from your .env file to your deployed app: + ```bash fly secrets import < .env ``` diff --git a/app.js b/app.js index c862debc..af8cdc0f 100644 --- a/app.js +++ b/app.js @@ -1,114 +1,167 @@ -require('dotenv').config(); -require('colors'); +require("colors"); -const express = require('express'); -const ExpressWs = require('express-ws'); +const cfg = require("./config"); -const { GptService } = require('./services/gpt-service'); -const { StreamService } = require('./services/stream-service'); -const { TranscriptionService } = require('./services/transcription-service'); -const { TextToSpeechService } = require('./services/tts-service'); -const { recordingService } = require('./services/recording-service'); +const express = require("express"); +const ExpressWs = require("express-ws"); -const VoiceResponse = require('twilio').twiml.VoiceResponse; +const { GptService } = require("./services/gpt-service-streaming"); +//const { GptService } = require("./services/gpt-service-non-streaming"); +const { TextService } = require("./services/text-service"); +const { EndSessionService } = require("./services/end-session-service"); -const app = express(); -ExpressWs(app); +const customerProfiles = require("./data/personalization"); -const PORT = process.env.PORT || 3000; +// Import helper functions +const { + processUserInputForHandoff, + handleLiveAgentHandoff, + handleDtmfInput, +} = require("./functions/helper-functions"); + +const { app } = ExpressWs(express()); +app.use(express.urlencoded({ extended: true })).use(express.json()); + +app.post("/incoming", (req, res) => { + console.log(`[App.js] Incoming call webhook, callSid ${req.body?.CallSid}`); -app.post('/incoming', (req, res) => { try { - const response = new VoiceResponse(); - const connect = response.connect(); - connect.stream({ url: `wss://${process.env.SERVER}/connection` }); - - res.type('text/xml'); - res.end(response.toString()); + // Build the response for Twilio's verb + const response = `\ + + + + + `; + + res.type("text/xml"); + res.send(response); } catch (err) { - console.log(err); + console.error(`[App.js] Error in /incoming route: ${err}`); + res.status(500).send("Internal Server Error"); } }); -app.ws('/connection', (ws) => { +app.ws("/sockets", (ws) => { try { - ws.on('error', console.error); - // Filled in from start message - let streamSid; - let callSid; + ws.on("error", console.error); const gptService = new GptService(); - const streamService = new StreamService(ws); - const transcriptionService = new TranscriptionService(); - const ttsService = new TextToSpeechService({}); - - let marks = []; + const endSessionService = new EndSessionService(ws); + const textService = new TextService(ws); + let interactionCount = 0; - - // Incoming from MediaStream - ws.on('message', function message(data) { - const msg = JSON.parse(data); - if (msg.event === 'start') { - streamSid = msg.start.streamSid; - callSid = msg.start.callSid; - - streamService.setStreamSid(streamSid); - gptService.setCallSid(callSid); - - // Set RECORDING_ENABLED='true' in .env to record calls - recordingService(ttsService, callSid).then(() => { - console.log(`Twilio -> Starting Media Stream for ${streamSid}`.underline.red); - ttsService.generate({partialResponseIndex: null, partialResponse: 'Hello! I understand you\'re looking for a pair of AirPods, is that correct?'}, 0); - }); - } else if (msg.event === 'media') { - transcriptionService.send(msg.media.payload); - } else if (msg.event === 'mark') { - const label = msg.mark.name; - console.log(`Twilio -> Audio completed mark (${msg.sequenceNumber}): ${label}`.red); - marks = marks.filter(m => m !== msg.mark.name); - } else if (msg.event === 'stop') { - console.log(`Twilio -> Media stream ${streamSid} ended.`.underline.red); + let awaitingUserInput = false; + let userProfile = null; + + // Handle incoming messages from the WebSocket + ws.on("message", async (data) => { + try { + const msg = JSON.parse(data); + console.log(`[App.js] Message received: ${JSON.stringify(msg)}`); + + // Handle DTMF input + if (msg.type === "dtmf" && msg.digit) { + console.log("[App.js] DTMF input received, processing..."); + awaitingUserInput = false; // Allow new input processing + interactionCount += 1; + await handleDtmfInput( + msg.digit, + gptService, + textService, + interactionCount, + userProfile + ); + return; + } + + if (awaitingUserInput) { + console.log("[App.js] Awaiting user input, skipping new API call."); + return; + } + + if (msg.type === "setup") { + // Extract information from the setup message + const phoneNumber = msg.from; // Caller's phone number + const smsSendNumber = msg.to; // Twilio's "to" number + const callSid = msg.callSid; // Call SID for call controls + + // Store phone numbers and callSid in gptService + gptService.setPhoneNumbers(smsSendNumber, phoneNumber); + gptService.setCallSid(callSid); + + // Retrieve user profile based on phone number + userProfile = customerProfiles[phoneNumber]; + + // Set the user profile in gptService + if (userProfile) { + gptService.setUserProfile(userProfile); + } + + // Generate a personalized greeting + const greetingText = userProfile + ? `Generate a warm, personalized greeting for ${userProfile.profile.firstName}, a returning prospect. Keep it brief, and use informal/casual language so you sound like a friend, not a call center agent.` + : "Generate a warm greeting for a new potential prospect. Keep it brief, and use informal/casual language so you sound like a friend, not a call center agent."; + + // Send the greeting as a system prompt to the assistant + await gptService.completion(greetingText, interactionCount, "system"); + + interactionCount += 1; + } else if ( + msg.type === "prompt" || + (msg.type === "interrupt" && msg.voicePrompt) + ) { + const trimmedVoicePrompt = msg.voicePrompt.trim(); + const shouldHandoff = await processUserInputForHandoff( + trimmedVoicePrompt + ); + + if (shouldHandoff) { + // Initiate live agent handoff + handleLiveAgentHandoff( + gptService, + endSessionService, + textService, + userProfile, + trimmedVoicePrompt + ); + return; // Exit after handoff + } + + // Process the user's voice prompt + awaitingUserInput = true; + await gptService.completion(trimmedVoicePrompt, interactionCount); + interactionCount += 1; + } + } catch (error) { + console.error(`[App.js] Error processing message: ${error}`); } }); - - transcriptionService.on('utterance', async (text) => { - // This is a bit of a hack to filter out empty utterances - if(marks.length > 0 && text?.length > 5) { - console.log('Twilio -> Interruption, Clearing stream'.red); - ws.send( - JSON.stringify({ - streamSid, - event: 'clear', - }) - ); + + // Listen for assistant replies + gptService.on( + "gptreply", + (gptReply, final, interactionCount, accumulatedText) => { + textService.sendText(gptReply, final, accumulatedText); + + if (final) { + awaitingUserInput = false; // Reset waiting state after final response + } } - }); - - transcriptionService.on('transcription', async (text) => { - if (!text) { return; } - console.log(`Interaction ${interactionCount} – STT -> GPT: ${text}`.yellow); - gptService.completion(text, interactionCount); - interactionCount += 1; - }); - - gptService.on('gptreply', async (gptReply, icount) => { - console.log(`Interaction ${icount}: GPT -> TTS: ${gptReply.partialResponse}`.green ); - ttsService.generate(gptReply, icount); - }); - - ttsService.on('speech', (responseIndex, audio, label, icount) => { - console.log(`Interaction ${icount}: TTS -> TWILIO: ${label}`.blue); - - streamService.buffer(responseIndex, audio); - }); - - streamService.on('audiosent', (markLabel) => { - marks.push(markLabel); + ); + + // Listen for session end events + gptService.on("endSession", (handoffData) => { + console.log( + `[App.js] Received endSession event: ${JSON.stringify(handoffData)}` + ); + endSessionService.endSession(handoffData); }); } catch (err) { - console.log(err); + console.error(`[App.js] Error in WebSocket connection: ${err}`); } }); -app.listen(PORT); -console.log(`Server running on port ${PORT}`); +app.listen(cfg.port, () => { + console.log(`Server running on port ${cfg.port}`); +}); diff --git a/config.js b/config.js new file mode 100644 index 00000000..f5c00f2b --- /dev/null +++ b/config.js @@ -0,0 +1,33 @@ +const dotenv = require("dotenv"); +const cfg = {}; + +if (process.env.NODE_ENV !== "test") { + dotenv.config({ path: ".env" }); +} else { + dotenv.config({ path: ".env.example", silent: true }); +} + +// HTTP Port to run our web application +cfg.port = process.env.PORT || 3000; + +cfg.server = process.env.SERVER; + +// Your Twilio account SID and auth token, both found at: +// https://www.twilio.com/user/account +// +// A good practice is to store these string values as system environment +// variables, and load them from there as we are doing below. Alternately, +// you could hard code these values here as strings. +cfg.accountSid = process.env.TWILIO_ACCOUNT_SID; + +cfg.twimlAppSid = process.env.TWILIO_TWIML_APP_SID; +cfg.callerId = process.env.TWILIO_CALLER_ID; + +cfg.apiKey = process.env.TWILIO_API_KEY; +cfg.apiSecret = process.env.TWILIO_API_SECRET; + +cfg.ttsProvider = process.env.TTS_PROVIDER ?? "amazon"; +cfg.ttsVoice = process.env.TTS_VOICE ?? "Danielle-Neural"; + +// Export configuration object +module.exports = cfg; diff --git a/data/mock-database.js b/data/mock-database.js new file mode 100644 index 00000000..448423c2 --- /dev/null +++ b/data/mock-database.js @@ -0,0 +1,313 @@ +const mockDatabase = { + availableAppointments: [ + // Existing Week + { + date: makeFutureDate(1), + time: "10:00 AM", + type: "in-person", + apartmentType: "one-bedroom", + }, + { + date: makeFutureDate(2), + time: "1:00 PM", + type: "in-person", + apartmentType: "two-bedroom", + }, + { + date: makeFutureDate(3), + time: "11:00 AM", + type: "self-guided", + apartmentType: "studio", + }, + { + date: makeFutureDate(3), + time: "2:00 PM", + type: "in-person", + apartmentType: "three-bedroom", + }, + { + date: makeFutureDate(3), + time: "3:00 PM", + type: "self-guided", + apartmentType: "one-bedroom", + }, + { + date: makeFutureDate(4), + time: "9:00 AM", + type: "in-person", + apartmentType: "two-bedroom", + }, + { + date: makeFutureDate(5), + time: "11:00 AM", + type: "in-person", + apartmentType: "two-bedroom", + }, + { + date: makeFutureDate(6), + time: "10:00 AM", + type: "self-guided", + apartmentType: "studio", + }, + { + date: makeFutureDate(6), + time: "4:00 PM", + type: "in-person", + apartmentType: "three-bedroom", + }, + + // Extended Week 1 + { + date: makeFutureDate(7), + time: "8:00 AM", + type: "in-person", + apartmentType: "studio", + }, + { + date: makeFutureDate(7), + time: "11:00 AM", + type: "in-person", + apartmentType: "one-bedroom", + }, + { + date: makeFutureDate(7), + time: "3:00 PM", + type: "self-guided", + apartmentType: "two-bedroom", + }, + { + date: makeFutureDate(8), + time: "1:00 PM", + type: "in-person", + apartmentType: "three-bedroom", + }, + { + date: makeFutureDate(8), + time: "4:00 PM", + type: "in-person", + apartmentType: "one-bedroom", + }, + { + date: makeFutureDate(9), + time: "9:00 AM", + type: "self-guided", + apartmentType: "studio", + }, + { + date: makeFutureDate(9), + time: "2:00 PM", + type: "in-person", + apartmentType: "two-bedroom", + }, + { + date: makeFutureDate(10), + time: "10:00 AM", + type: "in-person", + apartmentType: "three-bedroom", + }, + { + date: makeFutureDate(10), + time: "4:00 PM", + type: "self-guided", + apartmentType: "two-bedroom", + }, + { + date: makeFutureDate(11), + time: "12:00 PM", + type: "in-person", + apartmentType: "studio", + }, + + // Extended Week 2 + { + date: makeFutureDate(12), + time: "11:00 AM", + type: "in-person", + apartmentType: "two-bedroom", + }, + { + date: makeFutureDate(12), + time: "3:00 PM", + type: "in-person", + apartmentType: "three-bedroom", + }, + { + date: makeFutureDate(13), + time: "9:00 AM", + type: "self-guided", + apartmentType: "one-bedroom", + }, + { + date: makeFutureDate(13), + time: "2:00 PM", + type: "in-person", + apartmentType: "studio", + }, + { + date: makeFutureDate(14), + time: "4:00 PM", + type: "in-person", + apartmentType: "two-bedroom", + }, + { + date: makeFutureDate(14), + time: "12:00 PM", + type: "self-guided", + apartmentType: "three-bedroom", + }, + { + date: makeFutureDate(15), + time: "10:00 AM", + type: "in-person", + apartmentType: "one-bedroom", + }, + { + date: makeFutureDate(15), + time: "3:00 PM", + type: "in-person", + apartmentType: "two-bedroom", + }, + { + date: makeFutureDate(16), + time: "1:00 PM", + type: "in-person", + apartmentType: "three-bedroom", + }, + { + date: makeFutureDate(16), + time: "5:00 PM", + type: "self-guided", + apartmentType: "studio", + }, + ], + appointments: [], + apartmentDetails: { + studio: { + layout: "Studio", + squareFeet: 450, + rent: 1050, + moveInDate: makeFutureDayOfMonth(1, 1), + features: ["1 bathroom", "open kitchen", "private balcony"], + petPolicy: "No pets allowed.", + fees: { + applicationFee: 50, + securityDeposit: 300, + }, + parking: "1 reserved parking spot included.", + specials: "First month's rent free if you move in before 2024-11-30.", + incomeRequirements: "Income must be 2.5x the rent.", + utilities: + "Water, trash, and Wi-Fi internet included. Tenant pays electricity and gas.", + location: { + street: "1657 Coolidge Street", + city: "Missoula", + state: "Montana", + zipCode: "59802", + }, + }, + "one-bedroom": { + layout: "One-bedroom", + squareFeet: 600, + rent: 1200, + moveInDate: makeFutureDayOfMonth(1, 15), + features: ["1 bedroom", "1 bathroom", "walk-in closet"], + petPolicy: "Cats only. No dogs or any other animals.", + fees: { + applicationFee: 50, + securityDeposit: 400, + }, + parking: "1 reserved parking spot included.", + specials: "First month's rent free if you move in before 2024-11-25.", + incomeRequirements: "Income must be 3x the rent.", + utilities: + "Water, trash, gas, and Wi-Fi internet included. Tenant pays electricity.", + location: { + street: "1705 Adams Street", + city: "Missoula", + state: "Montana", + zipCode: "59802", + }, + }, + "two-bedroom": { + layout: "Two-bedroom", + squareFeet: 950, + rent: 1800, + moveInDate: makeFutureDayOfMonth(2, 1), + features: ["2 bedrooms", "2 bathrooms", "walk-in closets", "balcony"], + petPolicy: "Cats and dogs allowed, but only 1 each.", + fees: { + applicationFee: 50, + securityDeposit: 500, + }, + parking: "2 reserved parking spots included.", + specials: "Waived application fee if you move in before 2024-11-20.", + incomeRequirements: "Income must be 3x the rent.", + utilities: + "Water, trash, gas, and Wi-Fi internet included. Tenant pays electricity.", + location: { + street: "1833 Jefferson Avenue", + city: "Missoula", + state: "Montana", + zipCode: "59802", + }, + }, + "three-bedroom": { + layout: "Three-bedroom", + squareFeet: 1200, + rent: 2500, + moveInDate: makeFutureDayOfMonth(2, 1), + features: [ + "3 bedrooms", + "2 bathrooms", + "walk-in closets", + "private balcony", + "extra storage", + ], + petPolicy: + "Up to 2 dogs and 2 cats are allowed, and other small pets like hamsters are allowed as well. No more than 4 total pets.", + fees: { + applicationFee: 50, + securityDeposit: 600, + }, + parking: "2 reserved parking spots included.", + specials: "No move-in fees if you sign a 12-month lease.", + incomeRequirements: "Income must be 3x the rent.", + utilities: + "Water, trash, gas, and Wi-Fi internet included. Tenant pays electricity.", + location: { + street: "1945 Roosevelt Way", + city: "Missoula", + state: "Montana", + zipCode: "59802", + }, + }, + }, +}; + +function makeFutureDate(daysToAdd) { + if (daysToAdd === undefined) daysToAdd = Math.floor(Math.random() * 10) + 1; + + const dt = new Date(); + dt.setDate(dt.getDate() + daysToAdd); + + const year = dt.getFullYear(); + const mo = `${dt.getMonth() + 1}`.padStart(2, "0"); + const da = `${dt.getDate()}`.padStart(2, "0"); + + return `${year}-${mo}-${da}`; +} + +function makeFutureDayOfMonth(monthsToAdd, dayOfMonth = 1) { + if (monthsToAdd === undefined) + monthsToAdd = Math.floor(Math.random() * 3) + 1; + + const dt = new Date(); + dt.setMonth(dt.getMonth() + monthsToAdd); + + const year = dt.getFullYear(); + const mo = `${dt.getMonth() + 1}`.padStart(2, "0"); + const da = `${dt.getDate()}`.padStart(2, "0"); + return `${year}-${mo}-${dayOfMonth}`; +} + +module.exports = mockDatabase; diff --git a/data/personalization.example b/data/personalization.example new file mode 100644 index 00000000..0f7fb5f3 --- /dev/null +++ b/data/personalization.example @@ -0,0 +1,49 @@ +const customerProfiles = { + "+[E164_PHONE_NUMBER]": { + profile: { + firstName: "[FIRST_NAME]", + lastName: "[LAST_NAME]", + phoneNumber: "+[E164_PHONE_NUMBER]", + email: "[EMAIL_ADDRESS]", + preferredApartmentType: "[APARTMENT_TYPE]", // e.g., "studio", "one-bedroom", "two-bedroom", etc. + budget: [BUDGET_AMOUNT], // e.g., 1800 + moveInDate: "[MOVE_IN_DATE]", // e.g., "2024-09-25" + petOwner: [IS_PET_OWNER], // true or false + tourPreference: "[TOUR_PREFERENCE]", // e.g., "self-guided", "in-person" + }, + conversationHistory: [ + { + date: "[DATE_YYYY_MM_DD]", + summary: + "[CONVERSATION_SUMMARY_1]", // e.g., "User asked about one-bedroom apartments with balcony access. Assistant confirmed availability." + }, + { + date: "[DATE_YYYY_MM_DD]", + summary: + "[CONVERSATION_SUMMARY_2]", // e.g., "User inquired about the security features in the building." + }, + { + date: "[DATE_YYYY_MM_DD]", + summary: + "[CONVERSATION_SUMMARY_3]", // e.g., "User asked if electric vehicle charging stations are available." + }, + { + date: "[DATE_YYYY_MM_DD]", + summary: + "[CONVERSATION_SUMMARY_4]", // e.g., "User asked about the utility costs for one-bedroom apartments." + }, + { + date: "[DATE_YYYY_MM_DD]", + summary: + "[CONVERSATION_SUMMARY_5]", // e.g., "User inquired about the guest policy and overnight stays." + }, + { + date: "[DATE_YYYY_MM_DD]", + summary: + "[CONVERSATION_SUMMARY_6]", // e.g., "User asked about nearby public transportation options." + }, + ], + }, +}; + +module.exports = customerProfiles; diff --git a/functions/available-functions.js b/functions/available-functions.js new file mode 100644 index 00000000..55926bb6 --- /dev/null +++ b/functions/available-functions.js @@ -0,0 +1,442 @@ +const mockDatabase = require("../data/mock-database"); +const twilio = require("twilio"); + +// Send SMS using Twilio API +const accountSid = process.env.TWILIO_ACCOUNT_SID; +const authToken = process.env.TWILIO_AUTH_TOKEN; +const client = twilio(accountSid, authToken); + +// Call controls +async function endCall(args) { + const { callSid } = args; + try { + setTimeout(async () => { + const call = await client.calls(callSid).update({ + twiml: "", + }); + console.log("Call ended for:", call.sid); + }, 3000); + return { + status: "success", + message: `Call has ended`, + }; + } catch (error) { + console.error("Twilio end error: ", error); + return { + status: "error", + message: `An error occurred whilst trying to hangup`, + }; + } +} + +// Utility function to normalize various time formats to the database's 12-hour AM/PM format +function normalizeTimeFormat(time) { + // Check if time is already in the desired AM/PM format + if (/^(0?[1-9]|1[0-2]):[0-5][0-9] ?(AM|PM)$/i.test(time.trim())) { + return time.toUpperCase().trim(); // Return as-is if it's already correct + } + + // Handle 24-hour format (e.g., "14:00") + let [hour, minute] = time.split(":"); + minute = minute.replace(/[^0-9]/g, ""); // Clean any non-numeric characters from minutes + hour = parseInt(hour, 10); + + let period = "AM"; // Default to AM + + // Convert 24-hour to 12-hour format + if (hour >= 12) { + period = "PM"; + if (hour > 12) hour -= 12; + } else if (hour === 0) { + hour = 12; // Midnight is 12:00 AM + } + + // Pad minutes to ensure it's always two digits + minute = minute.padStart(2, "0"); + + // Return time in the database's expected format + return `${hour}:${minute} ${period}`; +} + +// Function to handle live agent handoff +async function liveAgentHandoff(args) { + const { reason, context } = args; + + // Log the reason for the handoff + console.log(`[LiveAgentHandoff] Initiating handoff with reason: ${reason}`); + if (context) { + console.log(`[LiveAgentHandoff] Context provided: ${context}`); + } + + // Create a result message for the LLM after processing the handoff tool call + return { + reason: reason, + context: context || "No additional context provided", + message: `Handoff initiated due to: ${reason}. Context: ${ + context || "No additional context provided." + }`, + }; +} +// Function to send SMS confirmation for a scheduled tour +async function sendAppointmentConfirmationSms(args) { + const { appointmentDetails, to, from, userProfile } = args; + + // Check if appointment details are complete + if ( + !appointmentDetails || + !appointmentDetails.date || + !appointmentDetails.time || + !appointmentDetails.type || + !appointmentDetails.apartmentType + ) { + return { + status: "error", + message: + "The SMS could not be sent because some appointment details were incomplete.", + }; + } + + // Check if phone numbers are available + if (!to || !from) { + return { + status: "error", + message: + "The SMS could not be sent because either the 'to' or 'from' phone numbers were missing.", + }; + } + + // Personalize the message using the userProfile + const name = userProfile?.firstName || "user"; + const apartmentType = appointmentDetails.apartmentType; + const tourType = + appointmentDetails.type === "in-person" ? "an in-person" : "a self-guided"; + const message = `Hi ${name}, your tour for a ${apartmentType} apartment at Parkview is confirmed for ${appointmentDetails.date} at ${appointmentDetails.time}. This will be ${tourType} tour. We'll be ready for your visit! Let us know if you have any questions.`; + + try { + const smsResponse = await client.messages.create({ + body: message, + from: to, // The "to" is the Twilio number (sender) + to: from, // The "from" is the user's phone number (recipient) + }); + + console.log( + `[sendAppointmentConfirmationSms] SMS sent successfully: ${smsResponse.sid}` + ); + + return { + status: "success", + message: `An SMS confirmation has been sent to ${from}.`, + }; + } catch (error) { + console.error( + `[sendAppointmentConfirmationSms] Error sending SMS: ${error.message}` + ); + return { + status: "error", + message: "An error occurred while sending the SMS confirmation.", + }; + } +} + +// Function to schedule a tour +async function scheduleTour(args) { + const { date, time, type, apartmentType } = args; + + console.log( + `[scheduleTour] Current available appointments:`, + mockDatabase.availableAppointments + ); + console.log(`[scheduleTour] Received arguments:`, args); + + // Normalize the time input + const normalizedTime = normalizeTimeFormat(time); + console.log(`[scheduleTour] Normalized Time: ${normalizedTime}`); + + // Find the index of the matching available appointment slot + const index = mockDatabase.availableAppointments.findIndex( + (slot) => + slot.date === date && + slot.time === normalizedTime && + slot.type === type && + slot.apartmentType === apartmentType + ); + + console.log(`[scheduleTour] Index found: ${index}`); + + // If no matching slot is found, return a message indicating unavailability + if (index === -1) { + console.log(`[scheduleTour] The requested slot is not available.`); + return { + available: false, + message: `The ${normalizedTime} slot on ${date} is no longer available. Would you like to choose another time or date?`, + }; + } + + // Schedule the appointment and remove the slot from available appointments + mockDatabase.appointments.push({ + date, + time: normalizedTime, + type, + apartmentType, + id: mockDatabase.appointments.length + 1, + }); + mockDatabase.availableAppointments.splice(index, 1); // Remove the slot from available appointments + + console.log(`[scheduleTour] Appointment successfully scheduled.`); + + // Return confirmation message for the successful scheduling + return { + available: true, + message: `Your tour is scheduled for ${date} at ${normalizedTime}. Would you like a confirmation via SMS?`, + }; +} + +// Function to check availability +async function checkAvailability(args) { + const { date, time, type, apartmentType } = args; + + console.log( + `[checkAvailability] Current available appointments:`, + mockDatabase.availableAppointments + ); + console.log(`[checkAvailability] Received arguments:`, args); + + // Step 1: Check for missing fields and create messages for the LLM to ask the user for them + const missingFields = []; + + if (!date) { + missingFields.push("date"); + } + + if (!type) { + missingFields.push("tour type (e.g., in-person or self-guided)"); + } + + if (!apartmentType) { + missingFields.push("apartment type (e.g., studio, one-bedroom, etc.)"); + } + + // If there are missing fields, return the structured message for the LLM to prompt the user + if (missingFields.length > 0) { + return { + missing_fields: missingFields, + message: `Please provide the following details: ${missingFields.join( + ", " + )}.`, + }; + } + + let normalizedTime = null; + if (time) { + normalizedTime = normalizeTimeFormat(time); + } + + // Step 2: Check for an exact match (date, time, type, apartmentType) + let exactMatchSlot = null; + if (time) { + exactMatchSlot = mockDatabase.availableAppointments.find( + (slot) => + slot.date === date && + slot.time === normalizedTime && + slot.type === type && + slot.apartmentType === apartmentType + ); + } + + if (exactMatchSlot) { + console.log(`[checkAvailability] Exact match found.`); + return { + availableSlots: [exactMatchSlot], + message: `The ${time} slot on ${date} is available for an ${type} tour of a ${apartmentType} apartment. Would you like to book this?`, + }; + } + + // Step 3: Check for similar matches (same date, type, apartmentType but different time) + let similarDateSlots = mockDatabase.availableAppointments.filter( + (slot) => + slot.date === date && + slot.type === type && + slot.apartmentType === apartmentType + ); + + if (similarDateSlots.length > 0) { + console.log( + `[checkAvailability] Similar matches found (different time on the same date).` + ); + return { + availableSlots: similarDateSlots, + message: `The ${time} slot on ${date} isn't available. Here are other available times for that day: ${similarDateSlots + .map((slot) => slot.time) + .join(", ")}. Would any of these work for you?`, + }; + } + + // Step 4: Check for broader matches (same type, apartmentType but different date) + let broaderSlots = mockDatabase.availableAppointments.filter( + (slot) => slot.type === type && slot.apartmentType === apartmentType + ); + + if (broaderSlots.length > 0) { + console.log(`[checkAvailability] Broader matches found (different date).`); + return { + availableSlots: broaderSlots, + message: `There are no available slots on ${date} for a ${apartmentType} apartment, but here are other available dates: ${broaderSlots + .map((slot) => `${slot.date} at ${slot.time}`) + .join(", ")}. Would any of these work for you?`, + }; + } + + // Step 5: If no matches are found at all + console.log(`[checkAvailability] No available slots found.`); + return { + availableSlots: [], + message: `There are no available slots for a ${apartmentType} apartment at this time. Would you like to explore other options or check availability later?`, + }; +} + +// Function to check existing appointments +async function checkExistingAppointments() { + const userAppointments = mockDatabase.appointments; + + // If user has appointments, return them + if (userAppointments.length > 0) { + return { + appointments: userAppointments, + message: `You have the following appointments scheduled: ${userAppointments + .map( + (appt) => + `${appt.date} at ${appt.time} for a ${appt.apartmentType} tour (${appt.type} tour).` + ) + .join("\n")}`, + }; + } else { + // No appointments found + return { + appointments: [], + message: + "You don't have any appointments scheduled. Would you like to book a tour or check availability?", + }; + } +} + +// Function to handle common inquiries +async function commonInquiries({ inquiryType, apartmentType }) { + // Map the inquiry types to the database field names + const inquiryMapping = { + "pet policy": "petPolicy", + "income requirements": "incomeRequirements", + location: "location", + address: "location", // Map 'address' to 'location' as well + }; + + // If there's a mapped field, use it; otherwise, use the inquiryType directly + const inquiryField = inquiryMapping[inquiryType] || inquiryType; + + let inquiryDetails; + + if (apartmentType) { + // Return specific details for the given apartment type + inquiryDetails = mockDatabase.apartmentDetails[apartmentType][inquiryField]; + + // If inquiry is for location/address, format the location details + if (inquiryField === "location" && inquiryDetails) { + inquiryDetails = `${inquiryDetails.street}, ${inquiryDetails.city}, ${inquiryDetails.state}, ${inquiryDetails.zipCode}`; + } + } else { + // Return general details across all apartment types + inquiryDetails = Object.keys(mockDatabase.apartmentDetails) + .map((key) => { + const details = mockDatabase.apartmentDetails[key][inquiryField]; + if (inquiryField === "location" && details) { + return `${details.street}, ${details.city}, ${details.state}, ${details.zipCode}`; + } + return details; + }) + .filter(Boolean) + .join(" "); + } + + // Return the structured result based on the inquiryDetails + if (inquiryDetails) { + return { + inquiryDetails, + message: `Here are the details about ${inquiryType} for the ${ + apartmentType ? apartmentType : "available apartments" + }: ${inquiryDetails}`, + }; + } else { + // Return structured JSON indicating no information available + return { + inquiryDetails: null, + message: `I'm sorry, I don't have information about ${inquiryType}.`, + }; + } +} + +// Function to list available apartments +async function listAvailableApartments(args) { + try { + let apartments = Object.keys(mockDatabase.apartmentDetails).map((type) => ({ + type, + ...mockDatabase.apartmentDetails[type], + })); + + // Filter based on user input + if (args.date) { + apartments = apartments.filter( + (apt) => new Date(apt.moveInDate) <= new Date(args.date) + ); + } + if (args.budget) { + apartments = apartments.filter((apt) => apt.rent <= args.budget); + } + if (args.apartmentType) { + apartments = apartments.filter((apt) => apt.type === args.apartmentType); + } + + // Summarize available apartments + const summary = apartments + .map( + (apt) => + `${apt.layout}: ${apt.rent}/month, available from ${ + apt.moveInDate + }. Features: ${apt.features.join(", ")}.` + ) + .join("\n\n"); + + // If apartments are found, return the structured response + if (apartments.length > 0) { + return { + availableApartments: summary, + message: `Here are the available apartments based on your search: \n\n${summary}`, + }; + } else { + // No apartments found based on the filters + return { + availableApartments: [], + message: "No apartments are available that match your search criteria.", + }; + } + } catch (error) { + console.log( + `[listAvailableApartments] Error listing available apartments: ${error.message}` + ); + // Return error message as structured JSON + return { + availableApartments: null, + message: "An error occurred while listing available apartments.", + }; + } +} + +// Export all functions +module.exports = { + endCall, + liveAgentHandoff, + sendAppointmentConfirmationSms, + scheduleTour, + checkAvailability, + checkExistingAppointments, + commonInquiries, + listAvailableApartments, +}; diff --git a/functions/checkInventory.js b/functions/checkInventory.js deleted file mode 100644 index bb39161e..00000000 --- a/functions/checkInventory.js +++ /dev/null @@ -1,14 +0,0 @@ -async function checkInventory(functionArgs) { - const model = functionArgs.model; - console.log('GPT -> called checkInventory function'); - - if (model?.toLowerCase().includes('pro')) { - return JSON.stringify({ stock: 10 }); - } else if (model?.toLowerCase().includes('max')) { - return JSON.stringify({ stock: 0 }); - } else { - return JSON.stringify({ stock: 100 }); - } -} - -module.exports = checkInventory; \ No newline at end of file diff --git a/functions/checkPrice.js b/functions/checkPrice.js deleted file mode 100644 index ee2ce124..00000000 --- a/functions/checkPrice.js +++ /dev/null @@ -1,13 +0,0 @@ -async function checkPrice(functionArgs) { - let model = functionArgs.model; - console.log('GPT -> called checkPrice function'); - if (model?.toLowerCase().includes('pro')) { - return JSON.stringify({ price: 249 }); - } else if (model?.toLowerCase().includes('max')) { - return JSON.stringify({ price: 549 }); - } else { - return JSON.stringify({ price: 149 }); - } -} - -module.exports = checkPrice; \ No newline at end of file diff --git a/functions/function-manifest.js b/functions/function-manifest.js index 37fcdddb..35063a14 100644 --- a/functions/function-manifest.js +++ b/functions/function-manifest.js @@ -1,124 +1,253 @@ -// create metadata for all the available functions to pass to completions API const tools = [ { - type: 'function', + type: "function", function: { - name: 'checkInventory', - say: 'Let me check our inventory right now.', - description: 'Check the inventory of airpods, airpods pro or airpods max.', + name: "endCall", + description: + "Ends the call by hanging up when the user explicitly requests it or when the conversation has naturally concluded with no further actions required.", parameters: { - type: 'object', + type: "object", + properties: {}, + required: [], + }, + }, + }, + + { + type: "function", + function: { + name: "liveAgentHandoff", + description: + "Initiates a handoff to a live agent based on user request or sensitive topics.", + parameters: { + type: "object", properties: { - model: { - type: 'string', - 'enum': ['airpods', 'airpods pro', 'airpods max'], - description: 'The model of airpods, either the airpods, airpods pro or airpods max', + reason: { + type: "string", + description: + "The reason for the handoff, such as user request, legal issue, financial matter, or other sensitive topics.", + }, + context: { + type: "string", + description: + "Any relevant conversation context or details leading to the handoff.", }, }, - required: ['model'], + required: ["reason"], }, - returns: { - type: 'object', - properties: { - stock: { - type: 'integer', - description: 'An integer containing how many of the model are in currently in stock.' - } - } - } }, }, { - type: 'function', + type: "function", function: { - name: 'checkPrice', - say: 'Let me check the price, one moment.', - description: 'Check the price of given model of airpods, airpods pro or airpods max.', + name: "sendAppointmentConfirmationSms", + description: + "Sends an SMS confirmation for a scheduled tour to the user.", parameters: { - type: 'object', + type: "object", properties: { - model: { - type: 'string', - 'enum': ['airpods', 'airpods pro', 'airpods max'], - description: 'The model of airpods, either the airpods, airpods pro or airpods max', + appointmentDetails: { + type: "object", + properties: { + date: { + type: "string", + description: "The date of the scheduled tour (YYYY-MM-DD).", + }, + time: { + type: "string", + description: + "The time of the scheduled tour (e.g., '10:00 AM').", + }, + type: { + type: "string", + enum: ["in-person", "self-guided"], + description: + "The type of tour (either in-person or self-guided).", + }, + apartmentType: { + type: "string", + enum: ["studio", "one-bedroom", "two-bedroom", "three-bedroom"], + description: "The type of apartment for the tour.", + }, + }, + required: ["date", "time", "type", "apartmentType"], + }, + to: { + type: "string", + description: "The user's phone number (to send the SMS).", + }, + from: { + type: "string", + description: + "The phone number used to send the SMS (Twilio 'from' number).", + }, + userProfile: { + type: "object", + properties: { + firstName: { + type: "string", + description: "The user's first name.", + }, + lastName: { + type: "string", + description: "The user's last name.", + }, + email: { + type: "string", + description: "The user's email address.", + }, + }, + required: ["firstName"], }, }, - required: ['model'], + required: ["appointmentDetails", "to", "from", "userProfile"], }, - returns: { - type: 'object', - properties: { - price: { - type: 'integer', - description: 'the price of the model' - } - } - } }, }, { - type: 'function', + type: "function", function: { - name: 'placeOrder', - say: 'All right, I\'m just going to ring that up in our system.', - description: 'Places an order for a set of airpods.', + name: "scheduleTour", + description: "Schedules a tour for the user at the apartment complex.", parameters: { - type: 'object', + type: "object", properties: { - model: { - type: 'string', - 'enum': ['airpods', 'airpods pro'], - description: 'The model of airpods, either the regular or pro', + date: { + type: "string", + description: + "The date the user wants to schedule the tour for (YYYY-MM-DD). **Convert any relative date expressions to this format based on {{currentDate}}.**", + }, + time: { + type: "string", + description: + 'The time the user wants to schedule the tour for (e.g., "10:00 AM").', }, - quantity: { - type: 'integer', - description: 'The number of airpods they want to order', + type: { + type: "string", + enum: ["in-person", "self-guided"], + description: "The type of tour, either in-person or self-guided.", + }, + apartmentType: { + type: "string", + enum: ["studio", "one-bedroom", "two-bedroom", "three-bedroom"], + description: + "The layout of the apartment the user is interested in.", }, }, - required: ['type', 'quantity'], + required: ["date", "time", "type", "apartmentType"], }, - returns: { - type: 'object', + }, + }, + { + type: "function", + function: { + name: "checkAvailability", + description: + "Checks the availability of tour slots based on the user’s preferences.", + parameters: { + type: "object", properties: { - price: { - type: 'integer', - description: 'The total price of the order including tax' - }, - orderNumber: { - type: 'integer', - description: 'The order number associated with the order.' - } - } - } + date: { + type: "string", + description: + "The date the user wants to check for tour availability (YYYY-MM-DD). **Convert any relative date expressions to this format based on {{currentDate}}.**", + }, + time: { + type: "string", + description: + 'The time the user wants to check for availability (e.g., "10:00 AM").', + }, + type: { + type: "string", + enum: ["in-person", "self-guided"], + description: "The type of tour, either in-person or self-guided.", + }, + apartmentType: { + type: "string", + enum: ["studio", "one-bedroom", "two-bedroom", "three-bedroom"], + description: + "The layout of the apartment the user is interested in.", + }, + }, + required: ["date", "type", "apartmentType"], + }, }, }, { - type: 'function', + type: "function", function: { - name: 'transferCall', - say: 'One moment while I transfer your call.', - description: 'Transfers the customer to a live agent in case they request help from a real person.', + name: "listAvailableApartments", + description: + "Lists available apartments based on optional user criteria.", parameters: { - type: 'object', + type: "object", properties: { - callSid: { - type: 'string', - description: 'The unique identifier for the active phone call.', + date: { + type: "string", + description: + "The move-in date the user prefers (optional, YYYY-MM-DD). **Convert any relative date expressions to this format based on {{currentDate}}.**", + }, + budget: { + type: "integer", + description: + "The budget the user has for rent per month (optional).", + }, + apartmentType: { + type: "string", + enum: ["studio", "one-bedroom", "two-bedroom", "three-bedroom"], + description: + "The layout of the apartment the user is interested in (optional).", }, }, - required: ['callSid'], + required: [], }, - returns: { - type: 'object', + }, + }, + { + type: "function", + function: { + name: "checkExistingAppointments", + description: "Retrieves the list of appointments already booked.", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, + }, + { + type: "function", + function: { + name: "commonInquiries", + description: + "Handles common inquiries such as pet policy, fees, and other complex details, with the option to specify the apartment type.", + parameters: { + type: "object", properties: { - status: { - type: 'string', - description: 'Whether or not the customer call was successfully transfered' + inquiryType: { + type: "string", + enum: [ + "pet policy", + "fees", + "parking", + "specials", + "income requirements", + "utilities", + ], + description: + "The type of inquiry the user wants information about.", + }, + apartmentType: { + type: "string", + enum: ["studio", "one-bedroom", "two-bedroom", "three-bedroom"], + description: + "The apartment type for which the inquiry is being made (optional).", }, - } - } + }, + required: ["inquiryType"], + }, }, }, ]; -module.exports = tools; \ No newline at end of file +module.exports = tools; diff --git a/functions/helper-functions.js b/functions/helper-functions.js new file mode 100644 index 00000000..cac71d68 --- /dev/null +++ b/functions/helper-functions.js @@ -0,0 +1,404 @@ +// helper-functions.js + +// Helper function to format dates to 'YYYY-MM-DD' +function formatDate(date) { + return date.toISOString().split("T")[0]; +} + +// Helper function to add days to a date +function addDays(date, days) { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} + +// Function to generate a mock database +function generateMockDatabase(currentDate = new Date()) { + // Arrays for random selection + const apartmentTypes = [ + "studio", + "one-bedroom", + "two-bedroom", + "three-bedroom", + ]; + const tourTypes = ["in-person", "self-guided"]; + const times = [ + "9:00 AM", + "10:00 AM", + "11:00 AM", + "1:00 PM", + "2:00 PM", + "3:00 PM", + "4:00 PM", + "5:00 PM", + ]; + + // Generate available appointments for the next 20 days + const availableAppointments = Array.from({ length: 20 }, (_, i) => { + const date = addDays(currentDate, i + 1); + return { + date: formatDate(date), + time: times[Math.floor(Math.random() * times.length)], + type: tourTypes[Math.floor(Math.random() * tourTypes.length)], + apartmentType: + apartmentTypes[Math.floor(Math.random() * apartmentTypes.length)], + }; + }); + + // Dynamic move-in dates and specials for apartment details + const apartmentDetails = { + studio: { + layout: "Studio", + squareFeet: 450, + rent: 1050, + moveInDate: formatDate(addDays(currentDate, 15)), + features: [ + "Open floor plan", + "Compact kitchen with modern appliances", + "Large windows with city views", + "Walk-in shower", + "In-unit washer and dryer", + ], + amenities: [ + "Fitness center access", + "Community lounge", + "24-hour maintenance", + ], + petPolicy: "No pets allowed.", + fees: { + applicationFee: 50, + securityDeposit: 300, + }, + parking: "Street parking available.", + storage: "No additional storage available.", + specials: `First month's rent free if you move in before ${formatDate( + addDays(currentDate, 30) + )}.`, + leaseTerms: "12-month lease required.", + incomeRequirements: "Income must be 2.5x the rent.", + utilities: + "Water, trash, and Wi-Fi included. Tenant pays electricity and gas.", + location: { + street: "1657 Coolidge Street", + city: "Missoula", + state: "Montana", + zipCode: "59802", + }, + }, + "one-bedroom": { + layout: "One-bedroom", + squareFeet: 650, + rent: 1250, + moveInDate: formatDate(addDays(currentDate, 20)), + features: [ + "Separate bedroom", + "Full kitchen with dishwasher", + "Private balcony", + "Walk-in closet", + "In-unit washer and dryer", + ], + amenities: ["Swimming pool access", "Fitness center", "On-site laundry"], + petPolicy: "Cats allowed with a $200 pet deposit.", + fees: { + applicationFee: 50, + securityDeposit: 400, + petDeposit: 200, + }, + parking: "One reserved parking spot included.", + storage: "Additional storage units available for $50/month.", + specials: `Free parking for the first 6 months if you move in before ${formatDate( + addDays(currentDate, 35) + )}.`, + leaseTerms: "Flexible lease terms from 6 to 12 months.", + incomeRequirements: "Income must be 3x the rent.", + utilities: + "Water and trash included. Tenant pays electricity, gas, and internet.", + location: { + street: "1705 Adams Street", + city: "Missoula", + state: "Montana", + zipCode: "59802", + }, + }, + "two-bedroom": { + layout: "Two-bedroom", + squareFeet: 950, + rent: 1800, + moveInDate: formatDate(addDays(currentDate, 10)), + features: [ + "Two bedrooms", + "Two bathrooms", + "Open living and dining area", + "Modern kitchen with granite countertops", + "In-unit washer and dryer", + ], + amenities: [ + "Fitness center", + "Community garden", + "BBQ area", + "Covered parking", + ], + petPolicy: "Cats and small dogs allowed with a $250 pet deposit.", + fees: { + applicationFee: 50, + securityDeposit: 500, + petDeposit: 250, + }, + parking: "Two reserved covered parking spots included.", + storage: "Complimentary storage unit included.", + specials: `Reduced security deposit if you move in before ${formatDate( + addDays(currentDate, 25) + )}.`, + leaseTerms: "12-month lease preferred.", + incomeRequirements: "Income must be 3x the rent.", + utilities: + "Water, trash, and gas included. Tenant pays electricity and internet.", + location: { + street: "1833 Jefferson Avenue", + city: "Missoula", + state: "Montana", + zipCode: "59802", + }, + }, + "three-bedroom": { + layout: "Three-bedroom", + squareFeet: 1200, + rent: 2500, + moveInDate: formatDate(addDays(currentDate, 25)), + features: [ + "Three spacious bedrooms", + "Two bathrooms", + "Large kitchen with island", + "Private patio", + "Fireplace", + "In-unit washer and dryer", + "Smart home features", + ], + amenities: [ + "Swimming pool", + "Fitness center", + "Business center", + "Playground", + ], + petPolicy: "Pets allowed with breed restrictions; up to 2 pets.", + fees: { + applicationFee: 50, + securityDeposit: 600, + petDeposit: 300, + }, + parking: "Two reserved parking spots included.", + storage: "Large storage units available for $75/month.", + specials: "No application fee if you sign a 12-month lease.", + leaseTerms: "12 or 18-month lease options.", + incomeRequirements: "Income must be 3x the rent.", + utilities: + "Water, trash, gas, and Wi-Fi included. Tenant pays electricity.", + location: { + street: "1945 Roosevelt Way", + city: "Missoula", + state: "Montana", + zipCode: "59802", + }, + }, + }; + + return { + availableAppointments, + appointments: [], + apartmentDetails, + }; +} + +// Helper function to generate TTS messages for tools +function getTtsMessageForTool(toolName, userProfile, updateUserContext) { + const name = userProfile?.profile?.firstName || ""; + const nameIntroOptions = name + ? [ + `Sure ${name},`, + `Okay ${name},`, + `Alright ${name},`, + `Got it ${name},`, + `Certainly ${name},`, + ] + : ["Sure,", "Okay,", "Alright,", "Got it,", "Certainly,"]; + const randomIntro = + nameIntroOptions[Math.floor(Math.random() * nameIntroOptions.length)]; + + const toolMessages = { + listAvailableApartments: `${randomIntro} let me check on the available apartments for you.`, + checkExistingAppointments: `${randomIntro} I'll look up your existing appointments.`, + scheduleTour: `${randomIntro} I'll go ahead and schedule that tour for you.`, + checkAvailability: `${randomIntro} let me verify the availability for the requested time.`, + commonInquiries: `${randomIntro} one moment while I look that up.`, + sendAppointmentConfirmationSms: `${randomIntro} I'll send that SMS off to you shortly. Give it a few minutes, and you should see it come through.`, + liveAgentHandoff: `${randomIntro} that may be a challenging topic to discuss, so I'm going to get you over to a live agent. Hang tight.`, + }; + + const message = + toolMessages[toolName] || + `${randomIntro} give me a moment while I fetch the information.`; + + // Log the message to the userContext + updateUserContext("assistant", message); + + return message; +} +// Function to process user input for handoff +async function processUserInputForHandoff(userInput) { + const handoffKeywords = [ + "live agent", + "real person", + "talk to a representative", + "transfer me to a human", + "speak to a person", + "customer service", + ]; + + // Check if the input contains any of the keywords + if ( + handoffKeywords.some((keyword) => + userInput.toLowerCase().includes(keyword.toLowerCase()) + ) + ) { + console.log( + `[AppHelperFunctions] Live agent handoff requested by user input.` + ); + return true; // Signals that we should perform a handoff + } + return false; // No handoff needed +} + +// Function to handle live agent handoff +async function handleLiveAgentHandoff( + gptService, + endSessionService, + textService, + userProfile, + userInput +) { + const name = userProfile?.profile?.firstName + ? userProfile.profile.firstName + : ""; // Get user's name if available + + const nameIntroOptions = name + ? [ + `Sure ${name},`, + `Okay ${name},`, + `Alright ${name},`, + `Got it ${name},`, + `Certainly ${name},`, + ] + : ["Sure,", "Okay,", "Alright,", "Got it,", "Certainly,"]; + + const randomIntro = + nameIntroOptions[Math.floor(Math.random() * nameIntroOptions.length)]; + + const handoffMessages = [ + `${randomIntro} one moment, I'll transfer you to a live agent now.`, + `${randomIntro} let me get a live agent to assist you. One moment please.`, + `${randomIntro} I'll connect you with a live person right away. Just a moment.`, + `${randomIntro} sure thing, I'll transfer you to customer service. Please hold for a moment.`, + ]; + + const randomHandoffMessage = + handoffMessages[Math.floor(Math.random() * handoffMessages.length)]; + + console.log(`[AppHelperFunctions] Hand off message: ${randomHandoffMessage}`); + + // Send the random handoff message to the user + textService.sendText(randomHandoffMessage, true); // Final message before handoff + + // Add the final user input to userContext for summarization + gptService.updateUserContext("user", userInput); + + // Add the randomHandoffMessage to the userContext + gptService.updateUserContext("assistant", randomHandoffMessage); + + // Proceed with summarizing the conversation, including the latest messages + const conversationSummary = await gptService.summarizeConversation(); + + // End the session and include the conversation summary in the handoff data + // Introduce a delay before ending the session + setTimeout(() => { + // End the session and include the conversation summary in the handoff data + endSessionService.endSession({ + reasonCode: "live-agent-handoff", + reason: "User requested to speak to a live agent.", + conversationSummary: conversationSummary, + }); + }, 1000); // 1 second delay +} + +// Function to handle DTMF input +async function handleDtmfInput( + digit, + gptService, + textService, + interactionCount, + userProfile = null // Pass in the user profile +) { + const name = userProfile?.profile?.firstName + ? userProfile.profile.firstName + : ""; // Get user's name if available + + const nameIntroOptions = name + ? [ + `Sure ${name},`, + `Okay ${name},`, + `Alright ${name},`, + `Got it ${name},`, + `Certainly ${name},`, + ] + : ["Sure,", "Okay,", "Alright,", "Got it,", "Certainly,"]; + + const randomIntro = + nameIntroOptions[Math.floor(Math.random() * nameIntroOptions.length)]; + + switch (digit) { + case "1": + textService.sendText( + `${randomIntro} you want info on available apartments, let me get that for you, it will just take a few moments so hang tight.`, + true + ); // Send the message to the user + + // Process the request using gptService + await gptService.completion( + "Please provide a listing of all available apartments, but as a summary, not a list.", + interactionCount, + "user", + true // DTMF-triggered flag + ); + break; + + case "2": + textService.sendText( + `${randomIntro} you want me to check on your existing appointments, gimme one sec.`, + true + ); // Send the message to the user + + // Process the request using gptService + await gptService.completion( + "Please check all available scheduled appointments.", + interactionCount, + "user", + true // DTMF-triggered flag + ); + break; + + // Add more cases as needed for different DTMF inputs + default: + textService.sendText( + `Oops! That button’s a dud. But hey, press '1' to hear about available apartments or '2' to check your scheduled appointments!`, + true + ); // Send the default message + break; + } +} + +module.exports = { + generateMockDatabase, + getTtsMessageForTool, + processUserInputForHandoff, + handleLiveAgentHandoff, + handleDtmfInput, +}; diff --git a/functions/placeOrder.js b/functions/placeOrder.js deleted file mode 100644 index 428325d6..00000000 --- a/functions/placeOrder.js +++ /dev/null @@ -1,17 +0,0 @@ -async function placeOrder(functionArgs) { - const {model, quantity} = functionArgs; - console.log('GPT -> called placeOrder function'); - - // generate a random order number that is 7 digits - const orderNum = Math.floor(Math.random() * (9999999 - 1000000 + 1) + 1000000); - - // check model and return the order number and price with 7.9% sales tax - if (model?.toLowerCase().includes('pro')) { - return JSON.stringify({ orderNumber: orderNum, price: Math.floor(quantity * 249 * 1.079)}); - } else if (model?.toLowerCase().includes('max')) { - return JSON.stringify({ orderNumber: orderNum, price: Math.floor(quantity * 549 * 1.079) }); - } - return JSON.stringify({ orderNumber: orderNum, price: Math.floor(quantity * 179 * 1.079) }); -} - -module.exports = placeOrder; \ No newline at end of file diff --git a/functions/transferCall.js b/functions/transferCall.js deleted file mode 100644 index 383f4c53..00000000 --- a/functions/transferCall.js +++ /dev/null @@ -1,20 +0,0 @@ -require('dotenv').config(); - -const transferCall = async function (call) { - - console.log('Transferring call', call.callSid); - const accountSid = process.env.TWILIO_ACCOUNT_SID; - const authToken = process.env.TWILIO_AUTH_TOKEN; - const client = require('twilio')(accountSid, authToken); - - return await client.calls(call.callSid) - .update({twiml: `${process.env.TRANSFER_NUMBER}`}) - .then(() => { - return 'The call was transferred successfully, say goodbye to the customer.'; - }) - .catch(() => { - return 'The call was not transferred successfully, advise customer to call back later.'; - }); -}; - -module.exports = transferCall; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7bd5519a..f57a0482 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "1.1.0", "license": "MIT", "dependencies": { - "@deepgram/sdk": "^3.3.4", "colors": "^1.4.0", "dotenv": "^16.3.1", "express": "^4.19.2", @@ -590,33 +589,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "node_modules/@deepgram/captions": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@deepgram/captions/-/captions-1.2.0.tgz", - "integrity": "sha512-8B1C/oTxTxyHlSFubAhNRgCbQ2SQ5wwvtlByn8sDYZvdDtdn/VE2yEPZ4BvUnrKWmsbTQY6/ooLV+9Ka2qmDSQ==", - "dependencies": { - "dayjs": "^1.11.10" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@deepgram/sdk": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@deepgram/sdk/-/sdk-3.3.4.tgz", - "integrity": "sha512-DhkmQ1YhYFCUjdTzmEQZDTI1hFg+7qUtOMGgBknGESCbzEJ2Pt9bXaFk4IU8F9cz1cdGe5yUNyrVVJKQfnr/xg==", - "dependencies": { - "@deepgram/captions": "^1.1.1", - "@types/websocket": "^1.0.9", - "cross-fetch": "^3.1.5", - "deepmerge": "^4.3.1", - "events": "^3.3.0", - "websocket": "^1.0.34" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1560,14 +1532,6 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, - "node_modules/@types/websocket": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@types/websocket/-/websocket-1.0.10.tgz", - "integrity": "sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -2053,6 +2017,8 @@ "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", "hasInstallScript": true, + "optional": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -2380,14 +2346,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/cross-fetch": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", - "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", - "dependencies": { - "node-fetch": "^2.6.12" - } - }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2402,18 +2360,6 @@ "node": ">= 8" } }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/dayjs": { "version": "1.11.11", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", @@ -2459,6 +2405,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -2642,43 +2589,6 @@ "node": ">= 0.4" } }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/escalade": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", @@ -2837,20 +2747,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -2931,15 +2827,6 @@ "node": ">= 0.6" } }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -2948,14 +2835,6 @@ "node": ">=6" } }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3072,14 +2951,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dependencies": { - "type": "^2.7.2" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3761,11 +3632,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5544,11 +5410,6 @@ "node": ">= 0.6" } }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -5590,6 +5451,8 @@ "version": "4.8.1", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz", "integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==", + "optional": true, + "peer": true, "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -6704,11 +6567,6 @@ "node": ">=14.0" } }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6754,14 +6612,6 @@ "node": ">= 0.6" } }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -6834,6 +6684,8 @@ "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", "hasInstallScript": true, + "optional": true, + "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -6916,35 +6768,6 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, - "node_modules/websocket": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", - "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", - "dependencies": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.63", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/websocket/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/websocket/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -7084,14 +6907,6 @@ "node": ">=10" } }, - "node_modules/yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "engines": { - "node": ">=0.10.32" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 595b0a29..fa70d2ca 100644 --- a/package.json +++ b/package.json @@ -4,17 +4,14 @@ "description": "", "main": "index.js", "scripts": { - "inbound": "node ./scripts/inbound-call.js", - "outbound": "node ./scripts/outbound-call.js", - "test": "jest", "dev": "nodemon app.js", - "start": "node app.js" + "start": "node app.js", + "test": "jest" }, "keywords": [], "author": "Charlie Weems", "license": "MIT", "dependencies": { - "@deepgram/sdk": "^3.3.4", "colors": "^1.4.0", "dotenv": "^16.3.1", "express": "^4.19.2", diff --git a/prompts/prompt.js b/prompts/prompt.js new file mode 100644 index 00000000..b624ea2d --- /dev/null +++ b/prompts/prompt.js @@ -0,0 +1,69 @@ +const prompt = ` +## Objective +You are a voice AI agent assisting users with apartment leasing inquiries. Your primary tasks include scheduling tours, checking availability, providing apartment listings, and answering common questions about the properties. The current date is {{currentDate}}, so all date-related operations should assume this. + +## Guidelines +Voice AI Priority: This is a Voice AI system. Responses must be concise, direct, and conversational. Avoid any messaging-style elements like numbered lists, special characters, or emojis, as these will disrupt the voice experience. +Critical Instruction: Ensure all responses are optimized for voice interaction, focusing on brevity and clarity. Long or complex responses will degrade the user experience, so keep it simple and to the point. +Avoid repetition: Rephrase information if needed but avoid repeating exact phrases. +Be conversational: Use friendly, everyday language as if you are speaking to a friend. +Use emotions: Engage users by incorporating tone, humor, or empathy into your responses. +Always Validate: When a user makes a claim about apartment details (e.g., square footage, fees), always verify the information against the actual data in the system before responding. Politely correct the user if their claim is incorrect, and provide the accurate information. +DTMF Capabilities: Inform users that they can press '1' to list available apartments or '2' to check all currently scheduled appointments. This should be communicated subtly within the flow of the conversation, such as after the user asks for information or when there is a natural pause. +Avoid Assumptions: Difficult or sensitive questions that cannot be confidently answered authoritatively should result in a handoff to a live agent for further assistance. +Use Tools Frequently: Avoid implying that you will verify, research, or check something unless you are confident that a tool call will be triggered to perform that action. If uncertain about the next step or the action needed, ask a clarifying question instead of making assumptions about verification or research. + +## Context +Parkview Apartments is located in Missoula, Montana. All inquiries, listings, and availability pertain to this location. Ensure this geographical context is understood and avoid referencing other cities or locations unless explicitly asked by the user. + +## Function Call Guidelines +Order of Operations: + - Always check availability before scheduling a tour. + - Ensure all required information is collected before proceeding with a function call. + +### Schedule Tour: + - This function should only run as a single tool call, never with other tools + - This function can only be called after confirming availability, but it should NEVER be called when the user asks for or confirms they'd like an SMS Confirmation. + - Required data includes date, time, tour type (in-person or self-guided), and apartment type. + - If any required details are missing, prompt the user to provide them. + +### Check Availability: + - This function requires date, tour type, and apartment type. + - If any of these details are missing, ask the user for them before proceeding. + - If the user insists to hear availability, use the 'listAvailableApartments' function. + - If the requested time slot is unavailable, suggest alternatives and confirm with the user. + +### List Available Apartments: + - Trigger this function if the user asks for a list of available apartments or does not want to provide specific criteria. + - Also use this function when the user inquires about general availability without specifying detailed criteria. + - If criteria like move-in date, budget, or apartment layout are provided, filter results accordingly. + - Provide concise, brief, summarized responses. + +### Check Existing Appointments: + - Trigger this function if the user asks for details about their current appointments + - Provide concise, brief, summarized responses. + +### Common Inquiries: + - Use this function to handle questions related to pet policy, fees, parking, specials, location, address, and other property details. + - For any location or address inquiries, the system should always call the 'commonInquiries' function using the 'location' field. + - If the user provides an apartment type, retrieve the specific address associated with that type from the database. + - If no apartment type is specified, provide general location details. + +### Live Agent Handoff: + - Trigger the 'liveAgentHandoff' tool call if the user requests to speak to a live agent, mentions legal or liability topics, or any other sensitive subject where the AI cannot provide a definitive answer. + - Required data includes a reason code ("legal", "liability", "financial", or "user-requested") and a brief summary of the user query. + - If any of these situations arise, automatically trigger the liveAgentHandoff tool call. + +### SMS Confirmations: + - SMS confirmations should NEVER be coupled with function calls to 'scheduleTour'. + - Only offer to send an SMS confirmation if the user has successfully scheduled a tour, and the user agrees to receive one. + - If the user agrees, trigger the tool call 'sendAppointmentConfirmationSms' with the appointment details and the user's phone number, but do not trigger another 'scheduleTour' function call. + - Do not ask for the user's phone number if you've already been referencing them by name during the conversation. Assume the phone number is already available to the function. + +## Important Notes +- Always ensure the user's input is fully understood before making any function calls. +- If required details are missing, prompt the user to provide them before proceeding. + +`; + +module.exports = prompt; diff --git a/prompts/welcomePrompt.js b/prompts/welcomePrompt.js new file mode 100644 index 00000000..5b2cb2fe --- /dev/null +++ b/prompts/welcomePrompt.js @@ -0,0 +1,2 @@ +const welcomePrompt = `Hello, this is Emma with Parkview Apartments. Press 1 to hear available apartments, 2 for your appointments, or let me know how I can help you today!`; +module.exports = welcomePrompt; diff --git a/scripts/inbound-call.js b/scripts/inbound-call.js deleted file mode 100644 index a61b9e4d..00000000 --- a/scripts/inbound-call.js +++ /dev/null @@ -1,30 +0,0 @@ -require('dotenv').config(); - -// You can use this function to make a -// test call to your application by running -// npm inbound -async function makeInboundCall() { - const VoiceResponse = require('twilio').twiml.VoiceResponse; - const accountSid = process.env.TWILIO_ACCOUNT_SID; - const authToken = process.env.TWILIO_AUTH_TOKEN; - - const client = require('twilio')(accountSid, authToken); - - let twiml = new VoiceResponse(); - twiml.pause({ length: 10 }); - twiml.say('Which models of airpods do you have available right now?'); - twiml.pause({ length: 30 }); - twiml.hangup(); - - console.log(twiml.toString()); - - await client.calls - .create({ - twiml: twiml.toString(), - to: process.env.APP_NUMBER, - from: process.env.FROM_NUMBER - }) - .then(call => console.log(call.sid)); -} - -makeInboundCall(); \ No newline at end of file diff --git a/scripts/outbound-call.js b/scripts/outbound-call.js deleted file mode 100644 index 7f7c2d55..00000000 --- a/scripts/outbound-call.js +++ /dev/null @@ -1,23 +0,0 @@ -/* - You can use this script to place an outbound call - to your own mobile phone. -*/ - -require('dotenv').config(); - -async function makeOutBoundCall() { - const accountSid = process.env.TWILIO_ACCOUNT_SID; - const authToken = process.env.TWILIO_AUTH_TOKEN; - - const client = require('twilio')(accountSid, authToken); - - await client.calls - .create({ - url: `https://${process.env.SERVER}/incoming`, - to: process.env.YOUR_NUMBER, - from: process.env.FROM_NUMBER - }) - .then(call => console.log(call.sid)); -} - -makeOutBoundCall(); \ No newline at end of file diff --git a/services/end-session-service.js b/services/end-session-service.js new file mode 100644 index 00000000..7c4afe52 --- /dev/null +++ b/services/end-session-service.js @@ -0,0 +1,27 @@ +const EventEmitter = require("events"); + +class EndSessionService extends EventEmitter { + constructor(websocket) { + super(); + this.ws = websocket; + } + + // Method to end the session with handoff data + endSession(handoffData) { + const endSessionMessage = { + type: "end", + // Stringify the HandoffData content, which is an object + handoffData: JSON.stringify(handoffData), // This ensures it's a string as per the API requirements + }; + + console.log( + "[EndSessionService] Ending session with data: ", + endSessionMessage + ); + + // Send the entire end session message, with handoffData properly formatted + this.ws.send(JSON.stringify(endSessionMessage)); + } +} + +module.exports = { EndSessionService }; diff --git a/services/gpt-service-non-streaming.js b/services/gpt-service-non-streaming.js new file mode 100644 index 00000000..75fedf61 --- /dev/null +++ b/services/gpt-service-non-streaming.js @@ -0,0 +1,396 @@ +const OpenAI = require("openai"); // or the appropriate module import +const EventEmitter = require("events"); +const availableFunctions = require("../functions/available-functions"); +const tools = require("../functions/function-manifest"); +let prompt = require("../prompts/prompt"); +//const welcomePrompt = require("../prompts/welcomePrompt"); +const model = "gpt-4o"; + +const currentDate = new Date().toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", +}); + +prompt = prompt.replace("{{currentDate}}", currentDate); + +class GptService extends EventEmitter { + constructor() { + super(); + this.openai = new OpenAI(); + this.userContext = [ + { role: "system", content: prompt }, + // Only do this if you're going to use the WelcomePrompt in VoxRay config + // { + // role: "assistant", + // content: `${welcomePrompt}`, + // }, + ]; + this.smsSendNumber = null; // Store the "To" number (Twilio's "from") + this.phoneNumber = null; // Store the "From" number (user's phone) + } + + // Arrow function for getTtsMessageForTool, so it can access `this` + getTtsMessageForTool = (toolName) => { + const name = this.userProfile?.profile?.firstName + ? this.userProfile.profile.firstName + : ""; // Get the user's name if available + + const nameIntroOptions = name + ? [ + `Sure ${name},`, + `Okay ${name},`, + `Alright ${name},`, + `Got it ${name},`, + `Certainly ${name},`, + ] + : ["Sure,", "Okay,", "Alright,", "Got it,", "Certainly,"]; + + const randomIntro = + nameIntroOptions[Math.floor(Math.random() * nameIntroOptions.length)]; + + let message; + + switch (toolName) { + case "listAvailableApartments": + message = `${randomIntro} let me check on the available apartments for you.`; + break; + case "checkExistingAppointments": + message = `${randomIntro} I'll look up your existing appointments.`; + break; + case "scheduleTour": + message = `${randomIntro} I'll go ahead and schedule that tour for you.`; + break; + case "checkAvailability": + message = `${randomIntro} let me verify the availability for the requested time.`; + break; + case "commonInquiries": + message = `${randomIntro} one moment.`; + break; + case "sendAppointmentConfirmationSms": + message = `${randomIntro} I'll send that SMS off to you shortly, give it a few minutes and you should see it come through.`; + break; + case "liveAgentHandoff": + message = `${randomIntro} that may be a challenging topic to discuss, so I'm going to get you over to a live agent so they can discuss this with you, hang tight.`; + break; + case "complexRequest": + message = `${randomIntro} that's a bit of a complex request, gimme just a minute to try to sort that out.`; + break; + default: + message = `${randomIntro} give me a moment while I fetch the information.`; + break; + } + + // Log the message to the userContext in gptService + this.updateUserContext("assistant", message); + + return message; // Return the message for TTS + }; + + setUserProfile(userProfile) { + this.userProfile = userProfile; + if (userProfile) { + const { firstName } = userProfile.profile; + const historySummaries = userProfile.conversationHistory + .map( + (history) => + `On ${history.date}, ${firstName} asked: ${history.summary}` + ) + .join(" "); + // Add the conversation history to the system context + this.userContext.push({ + role: "system", + content: `${firstName} has had previous interactions. Conversation history: ${historySummaries}`, + }); + } + } + + // Method to store the phone numbers from app.js + setPhoneNumbers(smsSendNumber, phoneNumber) { + this.smsSendNumber = smsSendNumber; + this.phoneNumber = phoneNumber; + } + + // Method to retrieve the stored numbers (can be used in the function calls) + getPhoneNumbers() { + return { to: this.smsSendNumber, from: this.phoneNumber }; + } + // Store call SID from app.js + setCallSid(callSid) { + this.callSid = callSid; + } + + // Retrieve call SID + getCallSid() { + return { callSid: this.callSid }; + } + + log(message) { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] ${message}`); + } + + updateUserContext(role, text) { + this.userContext.push({ role: role, content: text }); + } + + async summarizeConversation() { + const summaryPrompt = "Summarize the conversation so far in 2-3 sentences."; + + // // Log the full userContext before making the API call + // console.log( + // `[GptService] Full userContext: ${JSON.stringify( + // this.userContext, + // null, + // 2 + // )}` + // ); + + // // Validate and log each message in userContext + // this.userContext.forEach((message, index) => { + // if (typeof message.content !== "string") { + // console.error( + // `[GptService] Invalid content type at index ${index}: ${JSON.stringify( + // message + // )}` + // ); + // } else { + // console.log( + // `[GptService] Valid content at index ${index}: ${message.content}` + // ); + // } + // }); + + const summaryResponse = await this.openai.chat.completions.create({ + model: model, + messages: [ + ...this.userContext, + { role: "system", content: summaryPrompt }, + ], + stream: false, // Non-streaming + }); + + const summary = summaryResponse.choices[0]?.message?.content || ""; + return summary; + } + + async completion( + text, + interactionCount, + role = "user", + dtmfTriggered = false + ) { + if (!text || typeof text !== "string") { + this.log(`[GptService] Invalid prompt received: ${text}`); + return; + } + + this.updateUserContext(role, text); + + try { + // Streaming is disabled explicitly + const response = await this.openai.chat.completions.create({ + model: model, + messages: this.userContext, + tools: tools, // Include the tools, so the model knows what functions are available + stream: false, // Always non-streaming + }); + + // Handle tool calls if detected + const toolCalls = response.choices[0]?.message?.tool_calls; + if (toolCalls && toolCalls.length > 0) { + this.log(`[GptService] Tool calls length: ${toolCalls.length} tool(s)`); + + const toolResponses = []; + let systemMessages = []; + let ttsMessage = ""; // Placeholder for TTS message + + for (const toolCall of toolCalls) { + this.log( + `[GptService] Tool call function: ${toolCall.function.name}` + ); + + const functionName = toolCall.function.name; + let functionArgs = JSON.parse(toolCall.function.arguments); + const functionToCall = availableFunctions[functionName]; + + let function_call_result_message; + + if (functionToCall) { + this.log( + `[GptService] Calling function ${functionName} with arguments: ${JSON.stringify( + functionArgs + )}` + ); + + // If there's more than one tool call, use a general TTS message + if (toolCalls.length > 1) { + ttsMessage = "Let me handle a few things for you, one moment."; + } else { + // For a single tool call, use the specific message + const functionName = toolCalls[0].function.name; + ttsMessage = this.getTtsMessageForTool(functionName); + } + + // Inject phone numbers if it's the SMS function + if (functionName === "sendAppointmentConfirmationSms") { + const phoneNumbers = this.getPhoneNumbers(); + functionArgs = { ...functionArgs, ...phoneNumbers }; + } + + // Inject callSid for call controls + if (toolCall.functionName === "endCall") { + functionArgs = { ...functionArgs, ...this.getCallSid() }; + } + const functionResponse = await functionToCall(functionArgs); + + function_call_result_message = { + role: "tool", + content: JSON.stringify(functionResponse), + tool_call_id: toolCall.id, + }; + + // Check if specific tool calls require additional system messages + if (functionName === "listAvailableApartments") { + systemMessages.push({ + role: "system", + content: + "Do not use asterisks (*) under any circumstances in this response. Summarize the available apartments in a readable format.", + }); + } + + // Personalize system messages based on user profile during relevant tool calls + if (functionName === "checkAvailability" && this.userProfile) { + const { firstName, moveInDate } = this.userProfile.profile; + systemMessages.push({ + role: "system", + content: `When checking availability for ${firstName}, remember that they are looking to move in on ${moveInDate}.`, + }); + } + + if (functionName === "scheduleTour" && functionResponse.available) { + // Inject a system message to ask about SMS confirmation + systemMessages.push({ + role: "system", + content: + "If the user agrees to receive an SMS confirmation, immediately trigger the 'sendAppointmentConfirmationSms' tool with the appointment details and the UserProfile. Do not ask for their phone number or any other details from the user. Do not call the 'scheduleTour' function again.", + }); + } + + // Check if the tool call is for the 'liveAgentHandoff' function + if (functionName === "liveAgentHandoff") { + // Proceed with summarizing the conversation, including the latest messages + // Introduce a delay before summarizing the conversation + setTimeout(async () => { + const conversationSummary = await this.summarizeConversation(); + + this.emit("endSession", { + reasonCode: "live-agent-handoff", + reason: functionResponse.reason, + conversationSummary: conversationSummary, + }); + + // Log the emission for debugging + this.log( + `[GptService] Emitting endSession event with reason: ${functionResponse.reason}` + ); + }, 3000); // 3-second delay + + // Log the emission for debugging + this.log( + `[GptService] Emitting endSession event with reason: ${functionResponse.reason}` + ); + } + + // Push the tool response to be used in the final completion call + toolResponses.push(function_call_result_message); + } else { + this.log( + `[GptService] No available function found for ${functionName}` + ); + } + } + + // Emit a single TTS message after processing all tool calls + if (!dtmfTriggered && ttsMessage) { + this.emit("gptreply", ttsMessage, true, interactionCount); + } + + // Prepare the chat completion call payload with the tool result + const completion_payload = { + model: model, + messages: [ + ...this.userContext, + ...systemMessages, // Inject dynamic system messages when relevant + response.choices[0].message, // the tool_call message + ...toolResponses, // The result of the tool calls + ], + }; + + // // Log the payload to the console + // console.log( + // `[GptService] Completion payload: ${JSON.stringify( + // completion_payload, + // null, + // 2 + // )}` + // ); + + // Call the API again to get the final response after tool processing + const finalResponse = await this.openai.chat.completions.create({ + model: completion_payload.model, + messages: completion_payload.messages, + stream: false, // Always non-streaming + }); + + const finalContent = finalResponse.choices[0]?.message?.content || ""; + this.userContext.push({ + role: "assistant", + content: finalContent, + }); + + // Emit the final response to the user + this.emit( + "gptreply", + finalContent, + true, + interactionCount, + finalContent + ); + return; // Exit after processing the tool call + } else { + // If no tool call is detected, emit the final completion response + const finalResponse = response.choices[0]?.message?.content || ""; + if (finalResponse.trim()) { + this.userContext.push({ + role: "assistant", + content: finalResponse, + }); + this.emit( + "gptreply", + finalResponse, + true, + interactionCount, + finalResponse + ); + } + } + } catch (error) { + this.log(`[GptService] Error during completion: ${error.stack}`); + + // Friendly response for any error encountered + const friendlyMessage = + "I apologize, that request might have been a bit too complex. Could you try asking one thing at a time? I'd be happy to help step by step!"; + + // Emit the friendly message to the user + this.emit("gptreply", friendlyMessage, true, interactionCount); + + // Push the message into the assistant context + this.updateUserContext("assistant", friendlyMessage); + + return; // Stop further processing + } + } +} +module.exports = { GptService }; diff --git a/services/gpt-service-streaming.js b/services/gpt-service-streaming.js new file mode 100644 index 00000000..f9d706f4 --- /dev/null +++ b/services/gpt-service-streaming.js @@ -0,0 +1,434 @@ +// Import necessary modules +const OpenAI = require("openai"); +const EventEmitter = require("events"); +const availableFunctions = require("../functions/available-functions"); +const tools = require("../functions/function-manifest"); +let prompt = require("../prompts/prompt"); +const model = "gpt-4o-mini"; + +// Import helper functions +const { + generateMockDatabase, + getTtsMessageForTool, +} = require("../functions/helper-functions"); + +// Set current date for prompt +const currentDate = new Date().toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", +}); +prompt = prompt.replace("{{currentDate}}", currentDate); + +class GptService extends EventEmitter { + constructor() { + super(); + this.openai = new OpenAI(); + this.userContext = [{ role: "system", content: prompt }]; + this.smsSendNumber = null; + this.phoneNumber = null; + this.callSid = null; + this.userProfile = null; + + // Generate dynamic mock database + this.mockDatabase = generateMockDatabase(); + + // No need to bind methods as helper functions are now imported + } + + // Set user profile and update context with conversation history + setUserProfile(userProfile) { + this.userProfile = userProfile; + if (userProfile) { + const { firstName } = userProfile.profile; + const historySummaries = userProfile.conversationHistory + .map( + (history) => + `On ${history.date}, ${firstName} asked: ${history.summary}` + ) + .join(" "); + this.userContext.push({ + role: "system", + content: `${firstName} has had previous interactions. Conversation history: ${historySummaries}`, + }); + } + } + + // Store phone numbers from app.js + setPhoneNumbers(smsSendNumber, phoneNumber) { + this.smsSendNumber = smsSendNumber; + this.phoneNumber = phoneNumber; + } + + // Retrieve stored numbers + getPhoneNumbers() { + return { to: this.smsSendNumber, from: this.phoneNumber }; + } + + // Store call SID from app.js + setCallSid(callSid) { + this.callSid = callSid; + } + + // Retrieve call SID + getCallSid() { + return { callSid: this.callSid }; + } + + // Logging utility + log(message) { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] ${message}`); + } + + // Update user context + updateUserContext(role, content) { + this.userContext.push({ role, content }); + } + + // Summarize conversation + async summarizeConversation() { + const summaryPrompt = "Summarize the conversation so far in 2-3 sentences."; + const summaryResponse = await this.openai.chat.completions.create({ + model, + messages: [ + ...this.userContext, + { role: "system", content: summaryPrompt }, + ], + }); + return summaryResponse.choices[0]?.message?.content || ""; + } + + // Main completion method + async completion( + text, + interactionCount, + role = "user", + dtmfTriggered = false + ) { + if (!text || typeof text !== "string") { + this.log(`[GptService] Invalid prompt received: ${text}`); + return; + } + + this.updateUserContext(role, text); + + try { + const response = await this.openai.chat.completions.create({ + model, + messages: this.userContext, + tools, + stream: true, + }); + + let toolCalls = {}; + let functionCallResults = []; + let contentAccumulator = ""; + let finalMessageObject = { + role: "assistant", + content: null, + tool_calls: [], + refusal: null, + }; + let currentToolCallId = null; + + for await (const chunk of response) { + const { choices } = chunk; + + // Handle tool calls + if (choices[0]?.delta?.tool_calls) { + const toolCall = choices[0].delta.tool_calls[0]; + if (toolCall.id && toolCall.id !== currentToolCallId) { + const isFirstToolCall = currentToolCallId === null; + currentToolCallId = toolCall.id; + + if (!toolCalls[currentToolCallId]) { + if (choices[0]?.delta?.content) { + this.emit( + "gptreply", + choices[0].delta.content, + true, + interactionCount, + contentAccumulator + ); + } else { + this.emit( + "gptreply", + "", + true, + interactionCount, + contentAccumulator + ); + } + + if (contentAccumulator.length > 0 && isFirstToolCall) { + this.userContext.push({ + role: "assistant", + content: contentAccumulator.trim(), + }); + + // // Emit TTS message related to the tool call + // if (!dtmfTriggered) { + // const ttsMessage = getTtsMessageForTool( + // toolCall.functionName, + // this.userProfile, + // this.updateUserContext.bind(this) + // ); + // this.emit( + // "gptreply", + // ttsMessage, + // true, + // interactionCount, + // ttsMessage + // ); + // } + } + + toolCalls[currentToolCallId] = { + id: currentToolCallId, + functionName: toolCall.function.name, + arguments: "", + }; + + this.log( + `[GptService] Detected new tool call: ${toolCall.function.name}` + ); + } + } + } + + // Finish reason is 'tool_calls' + if (choices[0]?.finish_reason === "tool_calls") { + this.log(`[GptService] All tool calls have been completed`); + const systemMessages = []; + + // Process each tool call + for (const toolCallId in toolCalls) { + const toolCall = toolCalls[toolCallId]; + let parsedArguments; + try { + parsedArguments = JSON.parse(toolCall.arguments); + } catch { + parsedArguments = toolCall.arguments; + } + // Log the function name and arguments after collecting all arguments + this.log( + `[GptService] Tool call function: ${toolCall.functionName}` + ); + this.log( + `[GptService] Calling function ${ + toolCall.functionName + } with arguments: ${JSON.stringify(parsedArguments)}` + ); + finalMessageObject.tool_calls.push({ + id: toolCall.id, + type: "function", + function: { + name: toolCall.functionName, + arguments: JSON.stringify(parsedArguments), + }, + }); + + // Inject phone numbers for SMS function + if (toolCall.functionName === "sendAppointmentConfirmationSms") { + parsedArguments = { + ...parsedArguments, + ...this.getPhoneNumbers(), + }; + } + + // Inject callSid for call controls + if (toolCall.functionName === "endCall") { + parsedArguments = { ...parsedArguments, ...this.getCallSid() }; + } + + // Call the respective function + const functionToCall = availableFunctions[toolCall.functionName]; + const functionResponse = await functionToCall(parsedArguments); + + // Store function call result + functionCallResults.push({ + role: "tool", + content: JSON.stringify(functionResponse), + tool_call_id: toolCall.id, + }); + + // Additional system messages + if (toolCall.functionName === "listAvailableApartments") { + systemMessages.push({ + role: "system", + content: + "Provide a summary of available apartments without using symbols or markdown.", + }); + } + + if ( + toolCall.functionName === "scheduleTour" && + functionResponse.available + ) { + systemMessages.push({ + role: "system", + content: + "If the user agrees to receive an SMS confirmation, immediately trigger the 'sendAppointmentConfirmationSms' tool with the appointment details and the UserProfile. Do not ask for their phone number or any other details from the user.", + }); + } + + if (toolCall.functionName === "liveAgentHandoff") { + setTimeout(async () => { + const conversationSummary = await this.summarizeConversation(); + this.emit("endSession", { + reasonCode: "live-agent-handoff", + reason: functionResponse.reason, + conversationSummary, + }); + this.log( + `[GptService] Emitting endSession event with reason: ${functionResponse.reason}` + ); + }, 3000); + } + } + + // Prepare the chat completion call payload + const completionPayload = { + model, + messages: [ + ...this.userContext, + ...systemMessages, + finalMessageObject, + ...functionCallResults, + ], + }; + + // Call the API again with streaming for final response + const finalResponseStream = await this.openai.chat.completions.create( + { + model: completionPayload.model, + messages: completionPayload.messages, + stream: true, + } + ); + + // Handle the final response stream + let finalContentAccumulator = ""; + for await (const chunk of finalResponseStream) { + const { choices } = chunk; + + if (!choices[0]?.delta?.tool_calls) { + if (choices[0].finish_reason === "stop") { + if (choices[0]?.delta?.content) { + finalContentAccumulator += choices[0].delta.content; + this.emit( + "gptreply", + choices[0].delta.content, + true, + interactionCount, + finalContentAccumulator + ); + } else { + this.emit( + "gptreply", + "", + true, + interactionCount, + finalContentAccumulator + ); + } + + this.userContext.push({ + role: "assistant", + content: finalContentAccumulator.trim(), + }); + + //Log the full userContext before making the API call + console.log( + `[GptService] Full userContext after tool call: ${JSON.stringify( + this.userContext, + null, + 2 + )}` + ); + break; + } else if ( + !choices[0]?.delta?.role && + choices[0]?.delta?.content + ) { + this.emit( + "gptreply", + choices[0].delta.content, + false, + interactionCount + ); + finalContentAccumulator += choices[0].delta.content; + } + } + } + + // Reset tool call state + toolCalls = {}; + currentToolCallId = null; + } else { + // Accumulate arguments for the current tool call + if (currentToolCallId && toolCalls[currentToolCallId]) { + if (choices[0]?.delta?.tool_calls[0]?.function?.arguments) { + toolCalls[currentToolCallId].arguments += + choices[0].delta.tool_calls[0].function.arguments; + } + } + } + + // Handle non-tool_call content chunks + if (!choices[0]?.delta?.tool_calls) { + if (choices[0].finish_reason === "stop") { + if (choices[0]?.delta?.content) { + contentAccumulator += choices[0].delta.content; + this.emit( + "gptreply", + choices[0].delta.content, + true, + interactionCount, + contentAccumulator + ); + } else { + this.emit( + "gptreply", + "", + true, + interactionCount, + contentAccumulator + ); + } + + this.userContext.push({ + role: "assistant", + content: contentAccumulator.trim(), + }); + break; + } else if (!choices[0]?.delta?.role && choices[0]?.delta?.content) { + this.emit( + "gptreply", + choices[0].delta.content, + false, + interactionCount + ); + contentAccumulator += choices[0].delta.content; + } + } + } + } catch (error) { + this.log(`[GptService] Error during completion: ${error.stack}`); + + // Friendly error message + const friendlyMessage = + "I apologize, that request might have been a bit too complex. Could you try asking one thing at a time? I'd be happy to help step by step!"; + + // Emit the friendly message + this.emit("gptreply", friendlyMessage, true, interactionCount); + + // Update user context + this.updateUserContext("assistant", friendlyMessage); + } + } +} + +module.exports = { GptService }; diff --git a/services/gpt-service.js b/services/gpt-service.js deleted file mode 100644 index 4defbb09..00000000 --- a/services/gpt-service.js +++ /dev/null @@ -1,138 +0,0 @@ -require('colors'); -const EventEmitter = require('events'); -const OpenAI = require('openai'); -const tools = require('../functions/function-manifest'); - -// Import all functions included in function manifest -// Note: the function name and file name must be the same -const availableFunctions = {}; -tools.forEach((tool) => { - let functionName = tool.function.name; - availableFunctions[functionName] = require(`../functions/${functionName}`); -}); - -class GptService extends EventEmitter { - constructor() { - super(); - this.openai = new OpenAI(); - this.userContext = [ - { 'role': 'system', 'content': 'You are an outbound sales representative selling Apple Airpods. You have a youthful and cheery personality. Keep your responses as brief as possible but make every attempt to keep the caller on the phone without being rude. Don\'t ask more than 1 question at a time. Don\'t make assumptions about what values to plug into functions. Ask for clarification if a user request is ambiguous. Speak out all prices to include the currency. Please help them decide between the airpods, airpods pro and airpods max by asking questions like \'Do you prefer headphones that go in your ear or over the ear?\'. If they are trying to choose between the airpods and airpods pro try asking them if they need noise canceling. Once you know which model they would like ask them how many they would like to purchase and try to get them to place an order. You must add a \'β€’\' symbol every 5 to 10 words at natural pauses where your response can be split for text to speech.' }, - { 'role': 'assistant', 'content': 'Hello! I understand you\'re looking for a pair of AirPods, is that correct?' }, - ], - this.partialResponseIndex = 0; - } - - // Add the callSid to the chat context in case - // ChatGPT decides to transfer the call. - setCallSid (callSid) { - this.userContext.push({ 'role': 'system', 'content': `callSid: ${callSid}` }); - } - - validateFunctionArgs (args) { - try { - return JSON.parse(args); - } catch (error) { - console.log('Warning: Double function arguments returned by OpenAI:', args); - // Seeing an error where sometimes we have two sets of args - if (args.indexOf('{') != args.lastIndexOf('{')) { - return JSON.parse(args.substring(args.indexOf(''), args.indexOf('}') + 1)); - } - } - } - - updateUserContext(name, role, text) { - if (name !== 'user') { - this.userContext.push({ 'role': role, 'name': name, 'content': text }); - } else { - this.userContext.push({ 'role': role, 'content': text }); - } - } - - async completion(text, interactionCount, role = 'user', name = 'user') { - this.updateUserContext(name, role, text); - - // Step 1: Send user transcription to Chat GPT - const stream = await this.openai.chat.completions.create({ - model: 'gpt-4-1106-preview', - messages: this.userContext, - tools: tools, - stream: true, - }); - - let completeResponse = ''; - let partialResponse = ''; - let functionName = ''; - let functionArgs = ''; - let finishReason = ''; - - function collectToolInformation(deltas) { - let name = deltas.tool_calls[0]?.function?.name || ''; - if (name != '') { - functionName = name; - } - let args = deltas.tool_calls[0]?.function?.arguments || ''; - if (args != '') { - // args are streamed as JSON string so we need to concatenate all chunks - functionArgs += args; - } - } - - for await (const chunk of stream) { - let content = chunk.choices[0]?.delta?.content || ''; - let deltas = chunk.choices[0].delta; - finishReason = chunk.choices[0].finish_reason; - - // Step 2: check if GPT wanted to call a function - if (deltas.tool_calls) { - // Step 3: Collect the tokens containing function data - collectToolInformation(deltas); - } - - // need to call function on behalf of Chat GPT with the arguments it parsed from the conversation - if (finishReason === 'tool_calls') { - // parse JSON string of args into JSON object - - const functionToCall = availableFunctions[functionName]; - const validatedArgs = this.validateFunctionArgs(functionArgs); - - // Say a pre-configured message from the function manifest - // before running the function. - const toolData = tools.find(tool => tool.function.name === functionName); - const say = toolData.function.say; - - this.emit('gptreply', { - partialResponseIndex: null, - partialResponse: say - }, interactionCount); - - let functionResponse = await functionToCall(validatedArgs); - - // Step 4: send the info on the function call and function response to GPT - this.updateUserContext(functionName, 'function', functionResponse); - - // call the completion function again but pass in the function response to have OpenAI generate a new assistant response - await this.completion(functionResponse, interactionCount, 'function', functionName); - } else { - // We use completeResponse for userContext - completeResponse += content; - // We use partialResponse to provide a chunk for TTS - partialResponse += content; - // Emit last partial response and add complete response to userContext - if (content.trim().slice(-1) === 'β€’' || finishReason === 'stop') { - const gptReply = { - partialResponseIndex: this.partialResponseIndex, - partialResponse - }; - - this.emit('gptreply', gptReply, interactionCount); - this.partialResponseIndex++; - partialResponse = ''; - } - } - } - this.userContext.push({'role': 'assistant', 'content': completeResponse}); - console.log(`GPT -> user context length: ${this.userContext.length}`.green); - } -} - -module.exports = { GptService }; diff --git a/services/recording-service.js b/services/recording-service.js deleted file mode 100644 index 9900abbb..00000000 --- a/services/recording-service.js +++ /dev/null @@ -1,23 +0,0 @@ - -require('colors'); - -async function recordingService(ttsService, callSid) { - try { - if (process.env.RECORDING_ENABLED === 'true') { - const client = require('twilio')(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); - - ttsService.generate({partialResponseIndex: null, partialResponse: 'This call will be recorded.'}, 0); - const recording = await client.calls(callSid) - .recordings - .create({ - recordingChannels: 'dual' - }); - - console.log(`Recording Created: ${recording.sid}`.red); - } - } catch (err) { - console.log(err); - } -} - -module.exports = { recordingService }; \ No newline at end of file diff --git a/services/stream-service.js b/services/stream-service.js deleted file mode 100644 index e8b844e5..00000000 --- a/services/stream-service.js +++ /dev/null @@ -1,60 +0,0 @@ -const EventEmitter = require('events'); -const uuid = require('uuid'); - -class StreamService extends EventEmitter { - constructor(websocket) { - super(); - this.ws = websocket; - this.expectedAudioIndex = 0; - this.audioBuffer = {}; - this.streamSid = ''; - } - - setStreamSid (streamSid) { - this.streamSid = streamSid; - } - - buffer (index, audio) { - // Escape hatch for intro message, which doesn't have an index - if(index === null) { - this.sendAudio(audio); - } else if(index === this.expectedAudioIndex) { - this.sendAudio(audio); - this.expectedAudioIndex++; - - while(Object.prototype.hasOwnProperty.call(this.audioBuffer, this.expectedAudioIndex)) { - const bufferedAudio = this.audioBuffer[this.expectedAudioIndex]; - this.sendAudio(bufferedAudio); - this.expectedAudioIndex++; - } - } else { - this.audioBuffer[index] = audio; - } - } - - sendAudio (audio) { - this.ws.send( - JSON.stringify({ - streamSid: this.streamSid, - event: 'media', - media: { - payload: audio, - }, - }) - ); - // When the media completes you will receive a `mark` message with the label - const markLabel = uuid.v4(); - this.ws.send( - JSON.stringify({ - streamSid: this.streamSid, - event: 'mark', - mark: { - name: markLabel - } - }) - ); - this.emit('audiosent', markLabel); - } -} - -module.exports = {StreamService}; \ No newline at end of file diff --git a/services/text-service.js b/services/text-service.js new file mode 100644 index 00000000..dd8c179b --- /dev/null +++ b/services/text-service.js @@ -0,0 +1,25 @@ +// text-service.js + +const EventEmitter = require("events"); + +class TextService extends EventEmitter { + constructor(websocket) { + super(); + this.ws = websocket; + } + + sendText(text, last, fullText = null) { + this.ws.send( + JSON.stringify({ + type: "text", + token: text, + last: last, + }) + ); + if (last && fullText) { + console.log("[TextService] Final Utterance:", fullText); + } + } +} + +module.exports = { TextService }; diff --git a/services/transcription-service.js b/services/transcription-service.js deleted file mode 100644 index 578fd80b..00000000 --- a/services/transcription-service.js +++ /dev/null @@ -1,94 +0,0 @@ -require('colors'); -const { createClient, LiveTranscriptionEvents } = require('@deepgram/sdk'); -const { Buffer } = require('node:buffer'); -const EventEmitter = require('events'); - - -class TranscriptionService extends EventEmitter { - constructor() { - super(); - const deepgram = createClient(process.env.DEEPGRAM_API_KEY); - this.dgConnection = deepgram.listen.live({ - encoding: 'mulaw', - sample_rate: '8000', - model: 'nova-2', - punctuate: true, - interim_results: true, - endpointing: 200, - utterance_end_ms: 1000 - }); - - this.finalResult = ''; - this.speechFinal = false; // used to determine if we have seen speech_final=true indicating that deepgram detected a natural pause in the speakers speech. - - this.dgConnection.on(LiveTranscriptionEvents.Open, () => { - this.dgConnection.on(LiveTranscriptionEvents.Transcript, (transcriptionEvent) => { - const alternatives = transcriptionEvent.channel?.alternatives; - let text = ''; - if (alternatives) { - text = alternatives[0]?.transcript; - } - - // if we receive an UtteranceEnd and speech_final has not already happened then we should consider this the end of of the human speech and emit the transcription - if (transcriptionEvent.type === 'UtteranceEnd') { - if (!this.speechFinal) { - console.log(`UtteranceEnd received before speechFinal, emit the text collected so far: ${this.finalResult}`.yellow); - this.emit('transcription', this.finalResult); - return; - } else { - console.log('STT -> Speech was already final when UtteranceEnd recevied'.yellow); - return; - } - } - - // console.log(text, "is_final: ", transcription?.is_final, "speech_final: ", transcription.speech_final); - // if is_final that means that this chunk of the transcription is accurate and we need to add it to the finalResult - if (transcriptionEvent.is_final === true && text.trim().length > 0) { - this.finalResult += ` ${text}`; - // if speech_final and is_final that means this text is accurate and it's a natural pause in the speakers speech. We need to send this to the assistant for processing - if (transcriptionEvent.speech_final === true) { - this.speechFinal = true; // this will prevent a utterance end which shows up after speechFinal from sending another response - this.emit('transcription', this.finalResult); - this.finalResult = ''; - } else { - // if we receive a message without speechFinal reset speechFinal to false, this will allow any subsequent utteranceEnd messages to properly indicate the end of a message - this.speechFinal = false; - } - } else { - this.emit('utterance', text); - } - }); - - this.dgConnection.on(LiveTranscriptionEvents.Error, (error) => { - console.error('STT -> deepgram error'); - console.error(error); - }); - - this.dgConnection.on(LiveTranscriptionEvents.Warning, (warning) => { - console.error('STT -> deepgram warning'); - console.error(warning); - }); - - this.dgConnection.on(LiveTranscriptionEvents.Metadata, (metadata) => { - console.error('STT -> deepgram metadata'); - console.error(metadata); - }); - - this.dgConnection.on(LiveTranscriptionEvents.Close, () => { - console.log('STT -> Deepgram connection closed'.yellow); - }); - }); - } - - /** - * Send the payload to Deepgram - * @param {String} payload A base64 MULAW/8000 audio stream - */ - send(payload) { - if (this.dgConnection.getReadyState() === 1) { - this.dgConnection.send(Buffer.from(payload, 'base64')); - } - } -} - -module.exports = { TranscriptionService }; \ No newline at end of file diff --git a/services/tts-service.js b/services/tts-service.js deleted file mode 100644 index 7ca1aed6..00000000 --- a/services/tts-service.js +++ /dev/null @@ -1,53 +0,0 @@ -require('dotenv').config(); -const { Buffer } = require('node:buffer'); -const EventEmitter = require('events'); -const fetch = require('node-fetch'); - -class TextToSpeechService extends EventEmitter { - constructor() { - super(); - this.nextExpectedIndex = 0; - this.speechBuffer = {}; - } - - async generate(gptReply, interactionCount) { - const { partialResponseIndex, partialResponse } = gptReply; - - if (!partialResponse) { return; } - - try { - const response = await fetch( - `https://api.deepgram.com/v1/speak?model=${process.env.VOICE_MODEL}&encoding=mulaw&sample_rate=8000&container=none`, - { - method: 'POST', - headers: { - 'Authorization': `Token ${process.env.DEEPGRAM_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - text: partialResponse, - }), - } - ); - - if (response.status === 200) { - try { - const blob = await response.blob(); - const audioArrayBuffer = await blob.arrayBuffer(); - const base64String = Buffer.from(audioArrayBuffer).toString('base64'); - this.emit('speech', partialResponseIndex, base64String, partialResponse, interactionCount); - } catch (err) { - console.log(err); - } - } else { - console.log('Deepgram TTS error:'); - console.log(response); - } - } catch (err) { - console.error('Error occurred in TextToSpeech service'); - console.error(err); - } - } -} - -module.exports = { TextToSpeechService }; \ No newline at end of file diff --git a/test/checkInventory.test.js b/test/checkInventory.test.js deleted file mode 100644 index e65026ca..00000000 --- a/test/checkInventory.test.js +++ /dev/null @@ -1,13 +0,0 @@ -const checkInventory = require('../functions/checkInventory'); - -test('Expect Airpods Pro to have 10 units', () => { - expect(checkInventory({model: 'airpods pro'})).toBe('{"stock":10}'); -}); - -test('Expect Airpods Max to have 0 units', () => { - expect(checkInventory({model: 'airpods max'})).toBe('{"stock":0}'); -}); - -test('Expect all other values to have 100 units', () => { - expect(checkInventory({model: 'anything'})).toBe('{"stock":100}'); -}); \ No newline at end of file diff --git a/test/checkPrice.test.js b/test/checkPrice.test.js deleted file mode 100644 index e228c0eb..00000000 --- a/test/checkPrice.test.js +++ /dev/null @@ -1,13 +0,0 @@ -const checkPrice = require('../functions/checkPrice'); - -test('Expect Airpods Pro to cost $249', () => { - expect(checkPrice({model: 'airpods pro'})).toBe('{"price":249}'); -}); - -test('Expect Airpods Max to cost $549', () => { - expect(checkPrice({model: 'airpods max'})).toBe('{"price":549}'); -}); - -test('Expect all other models to cost $149', () => { - expect(checkPrice({model: 'anything'})).toBe('{"price":149}'); -}); \ No newline at end of file diff --git a/test/placeOrder.test.js b/test/placeOrder.test.js deleted file mode 100644 index 4c1b8f3d..00000000 --- a/test/placeOrder.test.js +++ /dev/null @@ -1,8 +0,0 @@ -const placeOrder = require('../functions/placeOrder'); - -test('Expect placeOrder to return an object with a price and order number', () => { - const order = JSON.parse(placeOrder({model: 'airpods pro', quantity: 10})); - - expect(order).toHaveProperty('orderNumber'); - expect(order).toHaveProperty('price'); -}); \ No newline at end of file diff --git a/test/transferCall.test.js b/test/transferCall.test.js deleted file mode 100644 index eaff272c..00000000 --- a/test/transferCall.test.js +++ /dev/null @@ -1,31 +0,0 @@ -require('dotenv').config(); -const setTimeout = require('timers/promises').setTimeout; -const transferCall = require('../functions/transferCall'); - -test('Expect transferCall to successfully redirect call', async () => { - - async function makeOutBoundCall() { - const accountSid = process.env.TWILIO_ACCOUNT_SID; - const authToken = process.env.TWILIO_AUTH_TOKEN; - - const client = require('twilio')(accountSid, authToken); - - const sid = await client.calls - .create({ - url: `https://${process.env.SERVER}/incoming`, - to: process.env.YOUR_NUMBER, - from: process.env.FROM_NUMBER - }) - .then(call => call.sid); - - return sid; - } - - const callSid = await makeOutBoundCall(); - console.log(callSid); - await setTimeout(10000); - - const transferResult = await transferCall(callSid); - - expect(transferResult).toBe('The call was transferred successfully'); -}, 20000); \ No newline at end of file