Welcome back to the second part of our two-part series on building an interactive customer service support application that empowers support agents with the help of AI. The goal is to enhance their important work by leveraging previously resolved answers for current open questions quickly and intuitively using vector search.
tl;dr In case you want to skip right to the implementation, you can find a fully working example of this application on GitHub along with detailed README instructions.
In part one, we set up all of our services we need for our application, including:
-
- Couchbase Capella
- Vonage Messages API
- OpenAI Embeddings API
We also scaffolded the backend of our application with Ruby on Rails. Now, we are going to write the code that is going to connect these three services and bring them together to support the work of customer service agents responding to user inquiries via WhatsApp.
First, let’s go ahead and define the business logic of our application.
Defining application business logic
One of the beautiful things about Ruby on Rails is its notion of convention over configuration. This means that we do not need to reinvent the wheel for where to put the business logic of our application. What we now build will be a simplified version. All of the application code will not be shown in this tutorial, but as mentioned at the beginning, the entire codebase is available to clone locally on GitHub.
In short, we will be defining our logic in model and controller files.
The application will have three models:
-
- Ticket
- User
- Agent
The application will also have two controllers:
-
- Messages Controller
- Dashboard Controller
Other parts of the application that will need to be configured include the Vonage, OpenAI and Couchbase initializers in config/initializers
, and the routes for the application in config/routes.rb
. All of those can be viewed on GitHub and copied directly from there to your own codebase.
Let’s start with the models.
Create models
It is true that classically Rails works with SQL databases right out of the box. What if you want to combine the best of Rails with the best of a JSON document database like Couchbase? That’s now possible to do so in a streamlined way thanks to the Couchbase Ruby ORM that makes available a lot of the ActiveRecord functionality in Rails for working with Couchbase. Each of our models will inherit from CouchbaseOrm::Base
to make the ORM’s methods available to it.
The Ticket model
A ticket in the application needs to hold information about the query, its current status, who asked it, the summary of the eventual resolution, and the vector embedding of that resolution. We can define each of those as attributes:
1 2 3 4 5 6 7 8 9 10 11 12 |
OPEN = 'open' RESOLVED = 'resolved' belongs_to :user belongs_to :agent, optional: true attribute :query, :string attribute :status, :string, default: OPEN attribute :summary, :string attribute :embedding, :array, type: :float, default: [] attribute :created_at, :datetime, default: -> { Time.now } attribute :updated_at, :datetime, default: -> { Time.now } |
As you can see above, we also define two constants, OPEN and RESOLVED, which are the two possible status states a ticket can be in. We also create the relationship of a ticket to a user and to an agent, optionally.
In addition to defining the attributes, we also want to create some methods on the ticket that can be accessed in our application. We want to create helper methods that can be used to check on the status of a ticket like such:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
def self.open_tickets where(status: OPEN).to_a end def self.resolved_tickets where(status: RESOLVED).to_a end def open? status == OPEN end def resolved? status == RESOLVED end def mark_as_resolved! update!(status: RESOLVED) end |
All together, the Ticket model will look like the following, including additional validations:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
class Ticket < CouchbaseOrm::Base OPEN = 'open' RESOLVED = 'resolved' belongs_to :user belongs_to :agent, optional: true attribute :query, :string attribute :status, :string, default: OPEN attribute :summary, :string attribute :embedding, :array, type: :float, default: [] attribute :created_at, :datetime, default: -> { Time.now } attribute :updated_at, :datetime, default: -> { Time.now } ensure_design_document! validates :query, presence: true before_save :set_timestamps def self.open_tickets where(status: OPEN).to_a end def self.resolved_tickets where(status: RESOLVED).to_a end def open? status == OPEN end def resolved? status == RESOLVED end def mark_as_resolved! update!(status: RESOLVED) end private def set_timestamps self.updated_at = Time.now end end |
Let’s go down a similar road for the User model.
The User model
A user in the application needs to be able to have many tickets since it’s possible a customer may be dealing with multiple issues they are seeking support for.
We also want to ensure that we can easily find a user by their WhatsApp number, so we’ll create a helper method on the model for that as well. In this helper method, a user will either be found by their number, or if not, then a new user will be created and a portion of their number will be used to populate the name field.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class User < CouchbaseOrm::Base has_many :tickets attribute :whatsapp_number, :string attribute :name, :string attribute :created_at, :datetime, default: -> { Time.now } attribute :updated_at, :datetime, default: -> { Time.now } validates :whatsapp_number, presence: true validates :name, presence: true before_save :set_timestamps private def set_timestamps self.updated_at = Time.now end def self.find_or_create_user_by_whatsapp_number(whatsapp_number) user = User.find_by(whatsapp_number: whatsapp_number) unless user user = User.create!(whatsapp_number: whatsapp_number, name: "User #{whatsapp_number[-4..-1]}") end user end end |
The last model we will create is the Agent model, let’s do it.
The Agent Model
In many ways, the Agent model parallels the User model in that they both have many tickets. One key distinction is the agent does not use their own WhatsApp number to communicate in the application. Rather, they communicate with users through the application dashboard and the Vonage Messages API programmatically sends their messages to the user’s WhatsApp inbox.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
require 'couchbase-orm' class Agent < CouchbaseOrm::Base has_many :tickets attribute :name, :string attribute :email, :string attribute :created_at, :datetime, default: -> { Time.now } attribute :updated_at, :datetime, default: -> { Time.now } validates :email, presence: true, uniqueness: true validates :name, presence: true before_save :set_timestamps private def set_timestamps self.updated_at = Time.now end end |
At this point, we have created our models. It’s not time to define the controllers.
Create controllers
The application will have two controllers that define what happens inside each route of the site. Namely, a dashboard_controller
that oversees the functionality of the Dashboard, and a messages_controller
that oversees the messaging functionality.
We’ll start with the Dashboard first.
The Dashboard controller
The index and show views will both be defined in the dashboard controller, along with helper methods to fetch the vector similarity of previously resolved tickets to support agents in their work with current tickets.
The Couchbase Ruby SDK includes many useful abstractions to make interacting with the functionality of Couchbase more streamlined. This includes creating a new vector search using Couchbase::VectorSearch.new
defining the arguments inside the instantiation of a new VectorSearch
object like such:
1 2 3 4 5 6 7 8 9 10 11 |
request = Couchbase::SearchRequest.new( Couchbase::VectorSearch.new( [ Couchbase::VectorQuery.new('embedding', embedding) do |q| q.num_candidates = 2 q.boost = 0.3 end ], Couchbase::Options::VectorSearch.new(vector_query_combination: :and) ) ) |
First, the VectorSearch
is wrapped inside a new instance of Couchbase::SearchRequest
, as the VectorSearch
is a type of a search request. Then a new Couchbase::VectorQuery
instance is passed to the VectorSearch
object specifying the field to search in (i.e., embedding) and the customer query converted into its own embedding as the second argument.
With this in mind on how to create a vector search using the Ruby SDK let’s take a look at the complete dashboard controller code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
class DashboardController < ApplicationController def index @open_tickets = Ticket.where(status: 'open').to_a.sort_by { |ticket| ticket.created_at }.reverse @resolved_tickets = Ticket.all.to_a.select { |ticket| ticket.status == Ticket::RESOLVED }.to_a.sort_by { |ticket| ticket.updated_at }.reverse end def show @ticket = find_ticket @user = find_user @suggestions = find_suggestions end private def find_ticket Ticket.find(params[:id]) end def find_user find_ticket.user end def find_suggestions search_similar_tickets(find_ticket.query) end def search_similar_tickets(query) embedding = OPENAI_CLIENT.embeddings( parameters: { model: "text-embedding-ada-002", input: query } )['data'][0]['embedding'] cluster = Couchbase::Cluster.connect( ENV['COUCHBASE_CONNECTION_STRING'], ENV['COUCHBASE_USERNAME'], ENV['COUCHBASE_PASSWORD'] ) bucket = cluster.bucket(ENV['COUCHBASE_BUCKET']) scope = bucket.scope('_default') request = Couchbase::SearchRequest.new( Couchbase::VectorSearch.new( [ Couchbase::VectorQuery.new('embedding', embedding) do |q| q.num_candidates = 2 q.boost = 0.3 end ], Couchbase::Options::VectorSearch.new(vector_query_combination: :and) ) ) result = scope.search('whatsapp_support_index', request) result.rows.map do |row| document = bucket.default_collection.get(row.id) { id: row.id, score: row.score, summary: document.content['summary'] } end end end |
With that code, we’ve defined the functional logic for both the main dashboard view and the view of each individual ticket in the dashboard.
The frontend code can be found on GitHub for both of these views and can be copied directly into your codebase or edited for your own specific needs.
The Messages Controller
Now we need to define the logic for all of the messages that will be received by and sent through the application via WhatsApp. The messages controller will also be responsible for ensuring that new tickets and users are created when a user messages for the first time.
It’s possible in a later iteration of the application that some of this functionality can be moved and separated out to different areas in the codebase, but for simplicity sake we will be keeping it all in the messages controller for now.
One of the great aspects of Rails is the ability to dynamically inject new content on the browser window without needing to use JavaScript. This is accomplished with ActionCable, a core feature of Rails. We will be also using ActionCable to update the dashboard individual ticket view with the latest messages sent and received with the Vonage Messages API.
As mentioned in the Using the Vonage Messages API section, for development purposes, we are building with the API sandbox, so as a result, our API call to send messages will be built manually as an HTTP POST request. Once you have completed the necessary steps outlined in the Vonage documentation to obtain a Meta WhatsApp business account, you can then use the Vonage Ruby SDK to abstract these HTTP requests for you.
Here is the code for the controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 |
class MessagesController < ApplicationController skip_before_action :verify_authenticity_token def inbound whatsapp_number = params[:from] text = params[:text] @reply = { sender: 'User', body: text, created_at: Time.current } @user = User.find_by(whatsapp_number: whatsapp_number) if @user open_ticket = Ticket.find_by(user_id: @user.id, status: Ticket::OPEN) if open_ticket.nil? Ticket.create!(user_id: @user.id, query: "Awaiting query", status: Ticket::OPEN) send_message(to: whatsapp_number, text: "Thank you for your message. Please describe your support query.") elsif open_ticket.query == "Awaiting query" open_ticket.update!(query: text) ActionCable.server.broadcast "messages_#{@user.id}", { reply: @reply, ticket: open_ticket } else ActionCable.server.broadcast "messages_#{@user.id}", { reply: @reply, ticket: open_ticket } end else @user = User.create!(whatsapp_number: whatsapp_number, name: "User #{whatsapp_number[-4..-1]}") send_message(to: whatsapp_number, text: "Thank you for your message. Please describe your support query.") Ticket.create!(user_id: @user.id, query: "Awaiting query", status: Ticket::OPEN) end respond_to do |format| format.turbo_stream { render turbo_stream: turbo_stream.append("messages_#{@user.id}", partial: "messages/reply", locals: { reply: @reply }) } end end def reply @ticket_id = params[:ticket_id] @message = params[:message] @resolved = params[:mark_as_resolved] == "1" @ticket = Ticket.find(@ticket_id) @user = @ticket.user @reply = { sender: 'Agent', body: @message, created_at: Time.current } ActionCable.server.broadcast "messages_#{@user.id}", { reply: @reply, ticket: @ticket } if @resolved embedding = OPENAI_CLIENT.embeddings( parameters: { model: "text-embedding-ada-002", input: @message } )['data'][0]['embedding'] @ticket.update!(summary: @message, status: Ticket::RESOLVED, embedding: embedding) send_message( to: @user.whatsapp_number, text: @message ) respond_to do |format| format.html { redirect_to request.referrer, notice: "Ticket marked as resolved." } end else send_message( to: @user.whatsapp_number, text: @message ) respond_to do |format| format.turbo_stream { render turbo_stream: turbo_stream.append("messages_#{@user.id}", partial: "messages/reply", locals: { reply: @reply }) } end end end def status head :ok end private def send_message(to:, text:) require 'net/http' require 'uri' require 'json' uri = URI.parse("https://messages-sandbox.nexmo.com/v1/messages") request = Net::HTTP::Post.new(uri) request.basic_auth(ENV['VONAGE_API_KEY'], ENV['VONAGE_API_SECRET']) request.content_type = 'application/json' request['Accept'] = 'application/json' request.body = { from: ENV['VONAGE_FROM_NUMBER'], to: to, message_type: 'text', text: text, channel: 'whatsapp' }.to_json http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = true response = http.request(request) puts "Response Code: #{response.code}" puts "Response Body: #{response.body}" end end |
With the creation of the messages controller, we have defined just about all of the functionality we need in our application.
Anything that you may be still trying to figure out is readily available in the fully complete and working example application on GitHub.
All that is left to do now is to run our application!
Running the application
To use the application, go ahead and open a terminal window and run bin/dev
inside the folder of the application. This will start up a development environment of the application. Now, in a separate terminal window, start ngrok, if you are using ngrok as described above to make your localhost environment externally accessible, by running ngrok http 3000
.
Once you have done both commands, you can open in your browser http://localhost:3000. You will see an empty dashboard of no tickets. That’s because you haven’t created one yet!
Go ahead and message the WhatsApp number provided to you in your Vonage Messages API sandbox. The application will reply asking you to explain your support question. Once you do, the dashboard in your browser will populate with the new ticket. You can then open up the ticket by clicking View and begin interacting with it.
Once you begin to have tickets marked as resolved, future tickets will be populated with a Suggested Solutions section as you can see in the above screenshot. Each suggested solution will be ranked by its similarity to the current support question.
In this way, we have created an application utilizing AI that empowers team members in their work and doesn’t seek to replace them in their jobs.
Wrapping up
By integrating Couchbase, Vonage, and OpenAI, we’ve built an application that empowers agents with quick access to relevant information, improving their efficiency and allowing them to focus on delivering excellent customer service. This project showcases how technology can be harnessed to support and elevate human roles in the workplace, leading to better outcomes for both employees and customers. Now that you’ve seen the potential, it’s time to apply these concepts to your own projects and continue innovating.
-
- Part 1 – AI in Action: Enhancing and Not Replacing Jobs
- Get started using Couchbase Capella today, sign up for free