
engine.config.ConfiguredStoryHandler.kt Maven / Gradle / Ivy
/*
* Copyright (C) 2017/2021 e-voyageurs technologies
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package ai.tock.bot.engine.config
import ai.tock.bot.admin.answer.AnswerConfigurationType.simple
import ai.tock.bot.admin.answer.BuiltInAnswerConfiguration
import ai.tock.bot.admin.answer.ScriptAnswerConfiguration
import ai.tock.bot.admin.answer.SimpleAnswer
import ai.tock.bot.admin.answer.SimpleAnswerConfiguration
import ai.tock.bot.admin.bot.BotApplicationConfigurationKey
import ai.tock.bot.admin.indicators.metric.MetricType
import ai.tock.bot.admin.story.StoryDefinitionAnswersContainer
import ai.tock.bot.admin.story.StoryDefinitionConfiguration
import ai.tock.bot.admin.story.StoryDefinitionConfigurationStep
import ai.tock.bot.admin.story.StoryDefinitionConfigurationStep.Step
import ai.tock.bot.admin.story.StoryDefinitionStepMetric
import ai.tock.bot.connector.ConnectorFeature.CAROUSEL
import ai.tock.bot.connector.media.MediaCardDescriptor
import ai.tock.bot.connector.media.MediaCarouselDescriptor
import ai.tock.bot.definition.Intent
import ai.tock.bot.definition.StoryDefinition
import ai.tock.bot.definition.StoryHandler
import ai.tock.bot.definition.StoryHandlerBase.Companion.isEndCalled
import ai.tock.bot.definition.StoryTag
import ai.tock.bot.engine.BotBus
import ai.tock.bot.engine.BotRepository
import ai.tock.bot.engine.action.SendSentence
import ai.tock.bot.engine.dialog.NextUserActionState
import ai.tock.bot.engine.message.ActionWrappedMessage
import ai.tock.bot.engine.message.MessagesList
import ai.tock.bot.engine.user.UserTimelineDAO
import ai.tock.nlp.api.client.model.Entity
import ai.tock.nlp.api.client.model.NlpIntentQualifier
import ai.tock.shared.injector
import com.github.salomonbrys.kodein.instance
import mu.KotlinLogging
/**
*
*/
internal class ConfiguredStoryHandler(
private val definition: BotDefinitionWrapper,
private val configuration: StoryDefinitionConfiguration,
private val configurationStoryHandler: BotConfigurationStoryHandler? = null,
) : StoryHandler {
companion object {
private val logger = KotlinLogging.logger {}
private const val VIEWED_STORIES_BUS_KEY = "_viewed_stories_tock_switch"
}
private val userTimelineDAO: UserTimelineDAO by injector.instance()
override fun handle(bus: BotBus) {
configurationStoryHandler?.handle(bus)
if (isEndCalled(bus)) {
return
}
configuration.mandatoryEntities.forEach { entity ->
// fallback from "generic" entity if the role is not present
val role = entity.role
val entityTypeName = entity.entityTypeName
if (role != entityTypeName &&
bus.entityValueDetails(role) == null &&
bus.hasActionEntity(entityTypeName)
) {
bus.dialog.state.changeValue(
role,
bus.entityValueDetails(entityTypeName)
?.let { v ->
v.copy(entity = Entity(v.entity.entityType, role))
}
)
bus.removeEntityValue(entityTypeName)
}
if (bus.entityValueDetails(role) == null && entity.hasCurrentAnswer()) {
// if the role is generic and there is another role in the entity list: skip
if (role != entityTypeName || bus.entities.none { entity.entityType == it.value.value?.entity?.entityType?.name }) {
// else send entity question
entity.send(bus)
switchStoryIfEnding(null, bus)
return@handle
}
}
}
// Manage metrics
manageMetrics(bus)
// Manage step
val busStep = bus.step as? Step
busStep?.configuration
?.also { step ->
if (step.hasCurrentAnswer()) {
step.send(bus)
}
val targetIntent = step.targetIntent?.name
?: (bus.intent.takeIf { !step.hasCurrentAnswer() }?.wrappedIntent()?.name)
bus.botDefinition
.takeIf { targetIntent != null }
?.findStoryDefinition(targetIntent, bus.applicationId)
?.takeUnless { it == bus.botDefinition.unknownStory }
?.takeUnless { bus.viewedStories.contains(it) }
?.apply {
bus.switchConfiguredStory(this, targetIntent ?: error("targetIntent is null??"))
return@handle
}
if (step.hasCurrentAnswer()) {
switchStoryIfEnding(step, bus)
return@handle
}
}
val configurationName = BotRepository.getConfigurationByApplicationId(BotApplicationConfigurationKey(bus))?.name
val answerContainer =
configurationName?.let { name -> configuration.configuredAnswers.firstOrNull { it.botConfiguration == name } }
?: configuration
removeAskAgainProcess(bus)
answerContainer.send(bus)
switchStoryIfEnding(null, bus)
// Restrict next intents if defined in story settings:
if (configuration.nextIntentsQualifiers.isNotEmpty()) {
val nextIntentsQualifiers: MutableList = configuration.nextIntentsQualifiers.toMutableList()
// Story steps (choices) intents are always allowed:
configuration.steps.forEach { step ->
val intentName: String? = step.intent?.name
if (intentName != null) {
nextIntentsQualifiers.add(NlpIntentQualifier(intentName, .5))
}
val targetIntentName: String? = step.targetIntent?.name
if (targetIntentName != null) {
nextIntentsQualifiers.add(NlpIntentQualifier(targetIntentName, .5))
}
}
bus.dialog.state.nextActionState = NextUserActionState(nextIntentsQualifiers.distinctBy { it.intent }.toList())
logger.debug { "bus.dialog.state.nextActionState : $bus.dialog.state.nextActionState " }
logger.debug { "NextIntentsQualifiers : ${bus.dialog.state.nextActionState} " }
}
}
/**
* Manage story and step metrics :
* If a story is handled, save a [MetricType.STORY_HANDLED] metric
* If a story has steps with metrics, then save [MetricType.QUESTION_ASKED] metrics for indicators
* If a step is handled, save all its metrics as [MetricType.QUESTION_REPLIED]
* If a step has a children with metrics, then save them as [MetricType.QUESTION_ASKED]
*/
private fun manageMetrics(bus: BotBus) {
val busStep = bus.step as? Step
if(busStep == null) {
// Save story handled metric if bot handle story and not a step
configuration.saveMetric(
bus.createMetric(MetricType.STORY_HANDLED)
)
// if story has steps with metrics then save all metrics as QuestionAsked
saveQuestionAskedMetrics(bus, configuration.steps.flatMap { it.metrics })
} else {
// Save step metric if bot handle story and not a step
busStep.configuration.metrics
.map { bus.createMetric(MetricType.QUESTION_REPLIED, it.indicatorName, it.indicatorValueName) }
.also {
if(it.isNotEmpty())
configuration.saveMetrics(it)
}
// if step has children with metrics then save all metrics as QuestionAsked
saveQuestionAskedMetrics(bus, busStep.children.flatMap { it.metrics })
}
}
/**
* Save distinct [MetricType.QUESTION_ASKED] metrics
*/
private fun saveQuestionAskedMetrics(bus: BotBus, metrics: List) {
metrics.map { it.indicatorName }
.distinct()
.map { bus.createMetric(MetricType.QUESTION_ASKED, it) }
.also {
if(it.isNotEmpty())
configuration.saveMetrics(it)
}
}
/**
* Remove the ask again process to the last story if no more ask again round available
*/
private fun removeAskAgainProcess(bus: BotBus) {
if (bus.dialog.stories.lastOrNull()?.definition?.hasTag(StoryTag.ASK_AGAIN) == true && bus.dialog.state.hasCurrentAskAgainProcess && bus.dialog.state.askAgainRound == 0) {
bus.dialog.state.hasCurrentAskAgainProcess = false
}
}
private fun isMissingMandatoryEntities(bus: BotBus): Boolean {
configuration.mandatoryEntities.forEach { entity ->
val role = entity.role
val entityTypeName = entity.entityTypeName
if (bus.entityValueDetails(role) == null && entity.hasCurrentAnswer()) {
// if the role is generic and there is an other role in the entity list: skip
if (role != entityTypeName || bus.entities.none { entity.entityType == it.value.value?.entity?.entityType?.name }) {
return true
}
}
}
return false
}
private fun switchStoryIfEnding(
step: StoryDefinitionConfigurationStep?,
bus: BotBus
) {
if (!isMissingMandatoryEntities(bus) && bus.story.definition.steps.isEmpty() || step?.hasNoChildren == true) {
configuration.findEnabledEndWithStoryId(bus.applicationId)
?.let { bus.botDefinition.findStoryDefinitionById(it, bus.applicationId) }
?.let {
// before switching story (Only for an ending rule), we need to save a snapshot with the current intent
if (bus.connectorData.saveTimeline){
userTimelineDAO.save(bus.userTimeline, bus.botDefinition, asynchronousProcess = false)
}
bus.switchConfiguredStory(it, it.mainIntent().name)
}
}
}
private val BotBus.viewedStories: Set
get() =
getBusContextValue>(VIEWED_STORIES_BUS_KEY) ?: emptySet()
private fun BotBus.switchConfiguredStory(target: StoryDefinition, newIntent: String) {
step = step?.takeUnless { story.definition == target }
setBusContextValue(VIEWED_STORIES_BUS_KEY, viewedStories + target)
handleAndSwitchStory(target, Intent(newIntent))
}
private fun StoryDefinitionAnswersContainer.send(bus: BotBus) {
findCurrentAnswer().apply {
when (this) {
null -> bus.fallbackAnswer()
is SimpleAnswerConfiguration -> bus.handleSimpleAnswer(this@send, this)
is ScriptAnswerConfiguration -> bus.handleScriptAnswer(this@send)
is BuiltInAnswerConfiguration ->
(bus.botDefinition as BotDefinitionWrapper).builtInStory(configuration.storyId)
.storyHandler.handle(bus)
else -> error("type not supported for now: $this")
}
}
}
override fun support(bus: BotBus): Double = 1.0
private fun BotBus.fallbackAnswer() =
botDefinition.unknownStory.storyHandler.handle(this)
private fun BotBus.handleSimpleAnswer(
container: StoryDefinitionAnswersContainer,
simple: SimpleAnswerConfiguration?
) {
if (simple == null) {
fallbackAnswer()
} else {
val isMissingMandatoryEntities = isMissingMandatoryEntities(this)
val steps = story.definition.steps.isNotEmpty()
val answers = fillCarousel(simple)
answers.takeUnless { it.isEmpty() }
?.let {
it.subList(0, it.size - 1)
.forEach { a ->
send(container, a)
}
it.last().apply {
val currentStep = (step as? Step)?.configuration
val endingStoryRule = configuration.findEnabledEndWithStoryId(applicationId) != null
send(
container, this,
isMissingMandatoryEntities ||
// No steps and no ending story
(!steps && !endingStoryRule) ||
// Steps not started
(steps && currentStep == null) ||
// Steps started with children
(currentStep?.hasNoChildren == false) ||
// Steps started with no children, no target intent, no ending story
(currentStep?.hasNoChildren == true && currentStep?.targetIntent == null && !endingStoryRule)
)
}
}
}
}
private fun BotBus.fillCarousel(simple: SimpleAnswerConfiguration): List {
val transformedAnswers = mutableListOf()
logger.debug { "Configured answers: ${simple.answers}" }
simple.answers.takeUnless { it.isEmpty() }
?.run {
forEach { a ->
if (
underlyingConnector.hasFeature(CAROUSEL, targetConnectorType) &&
a.mediaMessage?.checkValidity() == true &&
a.mediaMessage is MediaCardDescriptor &&
a.mediaMessage.fillCarousel
) {
val previousAnswer = transformedAnswers.lastOrNull()
val previousAnswerMedia = previousAnswer?.mediaMessage
if (previousAnswerMedia?.checkValidity() == true) {
when (previousAnswerMedia) {
is MediaCarouselDescriptor -> { // Add card to previous carousel
transformedAnswers.removeLast()
transformedAnswers.add(
previousAnswer.copy(
mediaMessage = previousAnswerMedia.copy(
cards = previousAnswerMedia.cards + a.mediaMessage
)
)
)
}
is MediaCardDescriptor -> {
// Merge current and previous card to a carousel
transformedAnswers.removeLast()
transformedAnswers.add(
previousAnswer.copy(
mediaMessage = MediaCarouselDescriptor(
listOf(previousAnswerMedia, a.mediaMessage)
)
)
)
}
else -> transformedAnswers.add(a)
}
} else transformedAnswers.add(a)
} else transformedAnswers.add(a)
}
}
logger.debug { "Transformed answers: $transformedAnswers" }
return transformedAnswers
}
private fun BotBus.send(container: StoryDefinitionAnswersContainer, answer: SimpleAnswer, end: Boolean = false) {
val label = translate(answer.key)
val suggestions = container.findNextSteps(this, configuration).map { this.translate(it) }
val connectorMessages =
answer.mediaMessage
?.takeIf { it.checkValidity() }
?.let {
underlyingConnector.toConnectorMessage(it.toMessage(this)).invoke(this)
}
?.let { messages ->
if (end && suggestions.isNotEmpty() && messages.isNotEmpty()) {
messages.take(messages.size - 1) +
(
underlyingConnector.addSuggestions(
messages.last(),
suggestions
).invoke(this)
?: messages.last()
)
} else {
messages
}
}
?: listOfNotNull(
suggestions.takeIf { suggestions.isNotEmpty() && end }
?.let { underlyingConnector.addSuggestions(label, suggestions).invoke(this) }
)
val actions = connectorMessages
.map {
SendSentence(
botId,
applicationId,
userId,
null,
mutableListOf(it)
)
}
.takeUnless { it.isEmpty() }
?: listOf(
SendSentence(
botId,
applicationId,
userId,
label
)
)
val messagesList = MessagesList(actions.map { ActionWrappedMessage(it, 0) })
val delay = if (answer.delay == -1L) botDefinition.defaultDelay(currentAnswerIndex) else answer.delay
if (end) {
end(messagesList, delay)
} else {
send(messagesList, delay)
}
}
private fun BotBus.handleScriptAnswer(container: StoryDefinitionAnswersContainer) {
container.storyDefinition(definition, configuration)
?.storyHandler
?.handle(this)
?: run {
logger.warn { "no story definition for configured script for $container - use unknown" }
handleSimpleAnswer(container, container.findAnswer(simple) as? SimpleAnswerConfiguration)
}
}
override fun equals(other: Any?): Boolean {
return (other as? ConfiguredStoryHandler)?.configuration == configuration
}
override fun hashCode(): Int = configuration.hashCode()
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy