Develop in Integrated bot mode
The Tock Integrated bot mode allows you to develop a bot using a Domain Specific Language (DSL) in Kotlin.
Unlike the Bot API mode, the Integrated bot mode allows you to exploit all the possibilities of the Tock platform, including:
- User context management
- Conversation history
- Advanced concepts such as entity fusion
- Etc.
Example of entity fusion: when a user asks for "tomorrow" in a sentence (let's call this entity date) then "rather in the evening" in a following sentence, the fusion allows to automatically update the entity (date) with the two complementary pieces of information: day and time slot in this example.
Warning: in this development mode, unlike the Bot API mode, it is necessary for the bot module to have a connection to the database (MongoDB) of the Tock platform used.
To fully understand what follows, it is recommended to master the basics of the Kotlin programming language.
Getting started with the framework
KDoc documentation
The framework documentation in KDoc format is available here.
bot-toolkit dependency
To use the conversational framework, you need to add the bot-toolkit dependency to the
Kotlin application/project.
For example in a Maven project:
<dependency>
<groupId>ai.tock</groupId>
<artifactId>bot-toolkit</artifactId>
<version>26.9.0</version>
</dependency>
Or in a Gradle project:
implementation 'ai.tock:bot-toolkit:26.9.0'
Aligning dependency versions with the Tock BOM
Since Tock 26.9.0, the tock-bom bill of materials lists the Tock artifacts and the versions of their
third-party dependencies (Kotlin, coroutines, kotlinx-serialization, Jackson, Netty/Vert.x, OkHttp, AWS SDK, Google...).
Without it, your build tool resolves these libraries on its own, and can end up with incompatible versions
on the classpath (for instance two versions of Netty or Jackson), because Maven does not apply the
dependencyManagement of Tock's parent pom to your project.
Importing the BOM gives you the versions Tock is built and tested with, and you no longer need to set
the version of each Tock dependency.
In a Maven project:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>ai.tock</groupId>
<artifactId>tock-bom</artifactId>
<version>26.9.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>ai.tock</groupId>
<artifactId>bot-toolkit</artifactId>
</dependency>
</dependencies>
In a Gradle project:
implementation platform('ai.tock:tock-bom:26.9.0')
implementation 'ai.tock:bot-toolkit'
Only the dependencyManagement section is imported: the BOM adds no dependency, plugin or property to your project.
With Maven, a version declared in your own dependencyManagement takes precedence over the BOM
if you need to override one.
A bot is a set of stories
Here is for example how the Open Data Bot is defined:
val openBot = bot(
"bot_open_data",
stories =
listOf(
greetings,
departures,
arrivals,
search
),
hello = greetings
)
This bot has an identifier (required - "bot_open_data") and a list of paths or stories.
A Story is a functional grouping that corresponds to a main intent and, optionally, to one or more so-called "secondary" intents (see Concepts).
Here the bot defines 4 paths: greetings, departures, arrivals and search.
The greetings path is declared as the main path, it will be presented by default at the beginning of a conversation:
hello = greetings.
A simple Story
How do you define a Story?
Here is a first simplified version of the greetings path:
val greetings = story("greetings") {
send("Welcome to the SNCF Open Data Bot! :)")
end("This is a demonstration bot of the Tock framework: https://github.com/theopenconversationkit/tock")
}
Note that in the body of the function, this is of type BotBus,
from which you can interact with the user, and which also allows access
to all available contextual elements.
Concretely this means that when the greetings intent is detected by the NLP model, the function above will be called by the Tock framework.
The bot therefore successively sends a first response sentence (bus.send()), then a second indicating that it is
the last sentence of its response using a bus.end().
Here is now the full version of greetings:
val greetings = story("greetings") {
//cleanup state
resetDialogState()
send("Welcome to the Open Data Sncf Bot! :)")
send("This is a demo bot of the Tock framework : https://github.com/theopenconversationkit/tock")
withMessenger {
buttonsTemplate(
"It is intentionally very limited, but ask him for a route or departures from a station and see the result! :) ",
postbackButton("Routes", search),
postbackButton("Departures", Departures),
postbackButton("Arrivals", Arrivals)
)
}
withWhatsAppCloud {
whatsAppCloudReplyButtonMessage(
"It is deliberately very limited, but ask him for a route or departures from a station and see the result! :)",
whatsAppCloudQuickReply("Routes", search),
whatsAppCloudQuickReply("Departures", Departures),
whatsAppCloudQuickReply("Arrivals", Arrivals)
)
}
end()
}
Two notions have been added:
-
resetDialogState()which allows you to start from an empty user context (forgetting any previous exchanges) -
the
withMessenger{}andwithWhatsAppCloud{}methods which allow you to define specific responses for each connector. Here is a text with buttons for Messenger, and a text with reply buttons for WhatsApp.
Start and connect the bot
To start the bot, simply add the following call to your main main:
registerAndInstallBot(openBot)
The openBot variable in the example is the bot you defined above.
Once the bot is started, it is also necessary to specify which connectors are used in the bot's administration interface, from the Settings > Configurations > Create a new configuration menu.
To learn more about the different channels and connectors, see this page.
Importing the configuration (dumps)
It is possible to export different configurations from Tock Studio, and then import them automatically when the bot starts.
Once the Tock Studio dumps files are exported to the bot's classpath,
one or more of the following functions can be called from the main:
importApplicationDump: imports an application from an dump of an application. Note: the import is ignored if the target application already exists.importNlpDump: imports an NLP model (intents, sentences, entities) from an dump NLP.importI18nDump: imports labels (aka i18n) from a dump of labels.
Example :
fun main(args: Array<String>) {
registerAndInstallBot(bot)
// Import application
importApplicationDump("/bot_app_dump.json")
// Import NLP model (intents, sentences, entities...)
importNlpDump("/bot_nlp_dump.json")
// Import story labels (aka i18n)
importI18nDump("/bot_labels_dump.json")
}
Going further
Of course, the StoryHandler of greetings is not context-dependent: the answer is always the same.
For the development of complex stories, we need an additional abstraction.
Secondary intents
The examples of this section and of the following ones come from the Open Data bot:
SearchDef,Place,PlaceValue,Section... are classes of this bot, not of the Tock API.
Here is the beginning of the definition of the search story:
val search = storyDef<SearchDef>(
"search",
otherStarterIntents = setOf(indicate_origin),
secondaryIntents = setOf(indicate_location)) {
}
search path defines a "start" secondary intent (indicate_origin)
and a simple secondary intent (indicate_location).
A "start" secondary intent is similar in every way to a main intent:
as soon as this intent is detected, the search story will be executed,
if the current story does not have this intent as a secondary intent.
For a simple secondary intent, on the other hand, the story will only be executed if the current story of the context is "already" the search story. Several different stories can therefore share the same secondary intents.
Manipulating entities
To retrieve entity values, a good practice is to define extensions.
For example, here is the code used to retrieve the destination entity:
val destinationEntity = openBot.entity("location", "destination")
var DialogEntityAccess.destination: Place?
get() = place(destinationEntity)
set(value) = setPlace(destinationEntity, value)
private fun DialogEntityAccess.place(entity: Entity): Place? = entityValue(entity, ::placeValue)?.place
private fun DialogEntityAccess.setPlace(entity: Entity, place: Place?) = changeEntityValue(entity, place?.let { PlaceValue(place) })
An entity of type location and role destination is created.
This is the corresponding entity in the NLP model.
A variable destination is defined, which will simplify the manipulation of this entity in the business code.
This variable contains the current value of the destination in the user context.
Here is a completed version of the search story that uses destination:
val search = storyDef<SearchDef>(
"search",
setOf(indicate_origin),
setOf(indicate_location)) {
//check mandatory entities
when {
destination == null -> end("For which destination?")
origin == null -> end("For which origin?")
departureDate == null -> end("When do you want to leave?")
}
}
If there is no value in the current context for the destination, the bot asks to specify the destination and leaves it there. Same for the origin or the departure date.
If the 3 mandatory values are specified, it goes to the actual response developed in the class (SearchDef).
The full version of this first part of the code is as follows:
val search = storyDef<SearchDef>(
"search",
setOf(indicate_origin),
setOf(indicate_location)) {
//handle generic location intent
if (isIntent(indicate_location) && location != null) {
if (destination == null || origin != null) {
destination = returnsAndRemoveLocation()
} else {
origin = returnsAndRemoveLocation()
}
}
//check mandatory entities
when {
destination == null -> end("For which destination?")
origin == null -> end("For what origin")
departureDate == null -> end("When do you wish to leave?")
}
}
In case the detected intent is indicate_location, we don't know if the indicated location represents the origin or the destination.
So a simple rule is coded:
If there is already an origin in the context and no destination, the new location is actually the destination.
Otherwise, it is the origin.
Using HandlerDef
In the definition of the search story above, you may have noticed the generic typing SearchDef.
Here is the code for this class:
@WhatsAppCloudHandler(WhatsAppCloudSearchConnector::class)
@MessengerHandler(MessengerSearchConnector::class)
class SearchDef(bus: BotBus) : HandlerDef<SearchConnector>(bus) {
private val d: Place = bus.destination!!
private val o: Place = bus.origin!!
private val date: LocalDateTime = bus.departureDate!!
override fun answer() {
send("From {0} to {1}", o, d)
send("Departure on {0}", date by datetimeFormat)
val journeys = SncfOpenDataClient.journey(o, d, date)
if (journeys.isEmpty()) {
end("Sorry, no route found: ")
} else {
send("Here is the first proposal :")
connector?.sendFirstJourney(journeys.first())
end()
}
}
}
SearchDef extends HandlerDef which is an alias of a class in the Tock framework.
This is usually where we will define the business code of complex journeys.
The code is relatively clear, but it contains an additional abstraction: SearchConnector.
SearchConnector is the class that defines the specific behavior for each connector, and the annotations
@WhatsAppCloudHandler(WhatsAppCloudSearchConnector::class) and @MessengerHandler(MessengerSearchConnector::class)
indicate the corresponding implementations for the different supported connectors (WhatsApp and Messenger respectively).
What would happen if there was no implementation for WhatsApp for example?
The method connector?.sendFirstJourney(journeys.first()) would not send the final response, since connector would be null.
Using ConnectorDef
Here is now a simplified version of SearchConnector:
sealed class SearchConnector(context: SearchDef) : ConnectorDef<SearchDef>(context) {
fun Section.title(): CharSequence = i18n("{0} - {1}", from, to)
fun sendFirstJourney(journey: Journey) = withMessage(sendFirstJourney(journey.publicTransportSections()))
abstract fun sendFirstJourney(sections: List<Section>): ConnectorMessage
}
And here is its implementation for Messenger:
class MessengerSearchConnector(context: SearchDef) : SearchConnector(context) {
override fun sendFirstJourney(sections: List<Section>): ConnectorMessage =
genericTemplate(
sections.map { section ->
with(section) {
genericElement(
title(),
content(),
trainImage
)
}
}
)
}
The code specific to each connector is thus correctly decoupled. The code common to each connector is present in SearchConnector and the behavior specific to
each connector is in the dedicated classes.
Using StoryStep
Sometimes it is necessary to remember the step at which the user is
in the current story. For this Tock provides the notion of StoryStep.
There are two types of StoryStep:
SimpleStoryStep
To be used in simple cases, for which we will manage the induced behavior directly:
enum class MyStep : SimpleStoryStep { a, b }
val story = storyWithSteps<MyStep>("intent") {
if(step == a) {
// ...
} else if(step == b) {
// ...
} else {
//default case
}
}
To modify the current step, two methods are available:
- Manually modify the step
val story = storyWithSteps<MyStep>("intent") {
//(...)
step = MyStep.a
// the step will be persisted as long as we stay in this story
}
- Use buttons or other quick replies
More details on this topic below.
StoryStep with behavior
In more complex cases, we want to be able to define a behavior for each step.
Using HandlerDef is then a prerequisite.
enum class MySteps : StoryStep<MyHandlerDef> {
//no specific behavior
display,
select {
// the "select" step will be automatically selected if the select sub-intent is detected
override val intent: IntentAware? = SecondaryIntent.select
//in this case the following response will be given
override fun answer(): MyHandlerDef.() -> Any? = {
end("I don't know yet how to select something")
}
},
disruption {
//only the response is configured
override fun answer(): MyHandlerDef.() -> Any? = {
end("some perturbation")
}
};
}
StoryStep.
Postback buttons & quick replies
Messenger provides this type of button, and most connectors with a GUI do the same.
Tock allows you to define the action performed when clicking on these buttons.
In the following example, the button will redirect to the search intent.
buttonsTemplate(
"The bot is very limited! Only itineraries are supported :)",
postbackButton("Itineraries", search)
)
It is possible to also define a StoryStep and dedicated parameters:
//To define parameters, the recommended practice is to extend the ParameterKey interface
enum class ChoiceParameter : ParameterKey {
nextResultDate, nextResultOrigin
}
buttonsTemplate(
"The bot is very limited! Only itineraries are supported :)",
postbackButton(
"Itineraries",
intent = search,
//if no step is indicated, the current step is used
step = MyStep.a,
parameters =
//This parameter is stored as a string (square brackets are used)
nextResultDate[nextDate] +
//this parameter is stored in json (parentheses are used)
nextResultOrigin(origin)
)
)
To retrieve the parameters of the button that was clicked:
val isClick = isChoiceAction()
val nextDate = choice(nextResultDate)
val nextOrigin : Locality = action.jsonChoice(nextResultOrigin)
Unit Tests
The Unit Tests page presents the framework provided to write unit tests with Tock.