diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..606cb97 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,43 @@ +# Handle line endings automatically for files detected as text +# and leave all files detected as binary untouched. +* text=auto + +gradlew text eol=lf + +# +# The above will handle all files NOT found below +# +# These files are text and should be normalized (Convert crlf => lf) +*.bash text eol=lf +*.css text diff=css +*.df text +*.htm text diff=html +*.html text diff=html +*.java text diff=java +*.js text +*.json text +*.jsp text +*.jspf text +*.jspx text +*.properties text +*.sh text eol=lf +*.tld text +*.txt text +*.tag text +*.tagx text +*.xml text +*.yml text + +# These files are binary and should be left untouched +# (binary is a macro for -text -diff) +*.class binary +*.dll binary +*.ear binary +*.gif binary +*.ico binary +*.jar binary +*.jpg binary +*.jpeg binary +*.png binary +*.so binary +*.war binary diff --git a/README.md b/README.md index f1d7120..81fe203 100644 --- a/README.md +++ b/README.md @@ -1,266 +1,263 @@ -# Mutters -A Java framework for building bot brains. Heavily inspired by Amazon Echo's natural language understanding model. - -Implements: - -* Templated and/or machine learning based identification of a user's intent based on their utterance. Support out of the box for OpenNLP or Facebook's fastText. -* Templated and/or machine learning based (NER) extraction of data from the user's utterance -* State management to support complex conversations, using either: - * a state machine - * Inkle's narrative scripting engine [Ink](http://www.inklestudios.com/ink/) -* Pluggable intent matching, named entity extraction and conversation state management so you can use our own implementations. - -## Example -The following is the code for a simple Taxi ordering bot. It uses OpenNLP machine learning to identify what the user is asking and to extract street addresses: - -``` -public class TaxiInkBot - extends InkBot -{ - - public TaxiInkBot(TaxiInkBotConfiguration config) - { - super(config); - } - -} -``` - -Which uses a configuration class to set up the NLP, Ink Story and functions that are used by the bot: - -``` -public class TaxiInkBotConfiguration - implements InkBotConfiguration -{ - - @Override - public IntentMatcher getIntentMatcher() - { - // model was built with OpenNLP whitespace tokenizer - OpenNLPTokenizer tokenizer = new OpenNLPTokenizer(WhitespaceTokenizer.INSTANCE); - - // use OpenNLP NER for slot matching - OpenNLPSlotMatcher slotMatcher = new OpenNLPSlotMatcher(tokenizer); - slotMatcher.addSlotModel("Address", "models/en-ner-address.bin"); - - // create intent matcher - OpenNLPIntentMatcher matcher = new OpenNLPIntentMatcher("models/en-cat-taxi-intents.bin", tokenizer, slotMatcher); - - Intent intent = new Intent("OrderTaxi"); - intent.addSlot(new LiteralSlot("Address")); - matcher.addIntent(intent); - - intent = new Intent("CancelTaxi"); - matcher.addIntent(intent); - - intent = new Intent("WhereTaxi"); - matcher.addIntent(intent); - - intent = new Intent("GaveAddress"); - intent.addSlot(new LiteralSlot("Address")); - matcher.addIntent(intent); - - intent = new Intent("Stop"); - matcher.addIntent(intent); - - intent = new Intent("Help"); - matcher.addIntent(intent); - - intent = new Intent("FavColor"); - matcher.addIntent(intent); - - return matcher; - } - - @Override - public String getStoryJson() - { - return StoryUtils.loadStoryJsonFromClassPath("taxibot.ink.json"); - } - - @Override - public List getInkFunctions() - { - List functions = new ArrayList<>(); - - functions.add(new InkBotFunction() - { - @Override - public void execute(CurrentResponse currentResponse, Session session, IntentMatch intentMatch, - Story story, String param) - { - try - { - // generate a fake order number based on address for demo - story.getVariablesState().set("taxiNo", - Integer - .toHexString(SessionUtils - .getStringFromSlotOrSession(intentMatch, session, "address", "").hashCode()) - .substring(0, 4)); - } - catch (Exception e) - { - throw new RuntimeException("Unable to set taxi no", e); - } - } - - @Override - public String getFunctionName() - { - return "ORDER_TAXI"; - } - }); - - return functions; - } - - @Override - public List getGlobalIntents() - { - List globalIntents = new ArrayList(); - - globalIntents.add(new GlobalIntent("Stop", "stop")); - globalIntents.add(new GlobalIntent("Help", "help")); - - return globalIntents; - } - - // ... -} -``` - -Here is a snippet of the training data used to train the intents that the bot understands: - -``` -OrderTaxi Order me a cab -OrderTaxi Book a cab -OrderTaxi Order me a taxi -OrderTaxi Book a taxi -OrderTaxi Get me a cab -OrderTaxi Order a cab -OrderTaxi Get me a taxi -OrderTaxi Order a taxi -OrderTaxi Send a cab to 123 Mountain Drive -OrderTaxi Pick me up at 456 Queen Street -CancelTaxi Cancel my taxi -CancelTaxi Cancel my cab -CancelTaxi Cancel my order -WhereTaxi Where is my taxi ? -WhereTaxi Where is my cab ? -WhereTaxi How far away is my taxi ? -WhereTaxi How far away is my cab ? -WhereTaxi How long until my cab is here ? -WhereTaxi How long until my taxi is here ? -GaveAddress My address is 173 Essex Drive -GaveAddress The address is 1407 Graymalkin Lane , Salem Center -GaveAddress 52 Festive Road -GaveAddress it's 6151 Richmond Street -GaveAddress The pick up is at 740 Evergreen Terrace -GaveAddress 890 Fifth Avenue -GaveAddress The address is 13 China Ave -GaveAddress 136 River Road -``` - -And some of the training data to teach the bot how to identify street addresses: - -``` -Send a cab to 123 Mountain Drive - -The Whitehouse can be found at 1600 Pennsylvania Avenue . In the United Kingdom , the official residence of the Prime Minister can be found at 10 Downing Street . - -In New Zealand , The Beehive can be found in Molesworth St . - -Pick me up at 456 Queen Street - -Some other famous addresses include : 263 Prinsengracht , Amsterdam , the Home of Anne Frank in her diary and 1060 West Addison Street , Chigaco the location of Wrigley Field . - -In Seinfield , Newman lives at Apartment 5E , 129 West 81st Street . -``` - -Lastly here are some snippets from the Ink file that is used to manage the conversation: - -``` -== start == -+ OrderTaxi -> order_taxi -+ CancelTaxi -> cancel_taxi -+ WhereTaxi -> where_taxi - -VAR address="" -VAR taxiNo = "" - -== order_taxi == -- (order_taxi_loop) -{ - - address == "": - -> request_address - - else: - -> order_the_taxi -} --> END - -= request_address -What is the pick up address ? -::SET_REPROMPT Where would you like to be picked up ? -::SET_HINT 123 Someplace Rd -+ GaveAddress -- -> order_taxi_loop - -= order_the_taxi -::ORDER_TAXI -Taxi {taxiNo} is on its way -::ADD_ATTACHMENT type::link url::http:\/\/trackcab.example.com/t/{taxiNo} title::Track your taxi here -::ADD_QUICK_REPLY Where is my taxi? -::ADD_QUICK_REPLY Cancel my taxi --> END -``` - -## Usage -If you are using Gradle you can pull (for the example above) the latest release with: - -``` -repositories { - mavenCentral() -} - -dependencies { - compile 'com.rabidgremlin:mutters-ink-bot:4.4.2' - compile 'com.rabidgremlin:mutters-opennlp-intent:4.4.2' - compile 'com.rabidgremlin:mutters-opennlp-ner:4.4.2' - compile 'com.rabidgremlin:mutters-slots:4.4.2' -} -``` - - -Currently SNAPSHOT builds are available in the Sonatype OSSRH repository. - -Using Gradle you can pull the snapshot using: - -``` -repositories { - mavenCentral() - maven { - url "https://oss.sonatype.org/content/repositories/snapshots/" - } -} - -dependencies { - compile 'com.rabidgremlin:mutters-ink-bot:4.4.2-SNAPSHOT' - compile 'com.rabidgremlin:mutters-opennlp-intent:4.4.2-SNAPSHOT' - compile 'com.rabidgremlin:mutters-opennlp-ner:4.4.2-SNAPSHOT' - compile 'com.rabidgremlin:mutters-slots:4.4.2-SNAPSHOT' -} -``` - -## Packaging -Mutters is packaged into multiple jars to reduce dependencies and improve plugability. - -| Package | Description | -| ------------------------ | -------------------------------------------------------------------------------- | -| mutters-core | Contains core classes, interfaces and utility classes | -| mutters-fasttext-intent | Intent matcher that uses the fastText document classifier | -| mutters-ink-bot | Implementation of a Bot that uses Inkle's Ink engine for conversation scripting | -| mutters-opennlp-intent | Intent matcher that uses OpenNLP's document classifier | -| mutters-opennlp-ner | Slot matcher that uses OpenNLP's named entity recognition framework | -| mutters-slots | Implementation of a number of generic Slots | -| mutters-statemachine-bot | Implementation of a Bot that uses a state machine for conversation flows | -| mutters-templated-intent | Intent matcher that uses templates for matching | +# Mutters +A Java framework for building bot brains. Heavily inspired by Amazon Echo's natural language understanding model. + +Implements: + +* Templated and/or machine learning based identification of a user's intent based on their utterance. Support out of the box for OpenNLP or Facebook's fastText. +* Templated and/or machine learning based (NER) extraction of data from the user's utterance +* State management to support complex conversations, using either: + * a state machine + * Inkle's narrative scripting engine [Ink](http://www.inklestudios.com/ink/) +* Pluggable intent matching, named entity extraction and conversation state management so you can use our own implementations. + +## Example +The following is the code for a simple Taxi ordering bot. It uses OpenNLP machine learning to identify what the user is asking and to extract street addresses: + +``` +public class TaxiInkBot + extends InkBot +{ + + public TaxiInkBot(TaxiInkBotConfiguration config) + { + super(config); + } + +} +``` + +Which uses a configuration class to set up the NLP, Ink Story and functions that are used by the bot: + +``` +public class TaxiInkBotConfiguration + implements InkBotConfiguration +{ + + @Override + public IntentMatcher getIntentMatcher() + { + // model was built with OpenNLP whitespace tokenizer + OpenNLPTokenizer tokenizer = new OpenNLPTokenizer(WhitespaceTokenizer.INSTANCE); + + // use OpenNLP NER for slot matching + OpenNLPSlotMatcher slotMatcher = new OpenNLPSlotMatcher(tokenizer); + slotMatcher.addSlotModel("Address", "models/en-ner-address.bin"); + + // create intent matcher + OpenNLPIntentMatcher matcher = new OpenNLPIntentMatcher("models/en-cat-taxi-intents.bin", tokenizer, slotMatcher); + + Intent intent = new Intent("OrderTaxi"); + intent.addSlot(new LiteralSlot("Address")); + matcher.addIntent(intent); + + intent = new Intent("CancelTaxi"); + matcher.addIntent(intent); + + intent = new Intent("WhereTaxi"); + matcher.addIntent(intent); + + intent = new Intent("GaveAddress"); + intent.addSlot(new LiteralSlot("Address")); + matcher.addIntent(intent); + + intent = new Intent("Stop"); + matcher.addIntent(intent); + + intent = new Intent("Help"); + matcher.addIntent(intent); + + intent = new Intent("FavColor"); + matcher.addIntent(intent); + + return matcher; + } + + @Override + public String getStoryJson() + { + return StoryUtils.loadStoryJsonFromClassPath("taxibot.ink.json"); + } + + @Override + public List getInkFunctions() + { + List functions = new ArrayList<>(); + + functions.add(new InkBotFunction() + { + @Override + public void execute(CurrentResponse currentResponse, Session session, IntentMatch intentMatch, + Story story, String param) + { + try + { + // generate a fake order number based on address for demo + story.getVariablesState().set("taxiNo", + Integer + .toHexString(SessionUtils + .getStringFromSlotOrSession(intentMatch, session, "address", "").hashCode()) + .substring(0, 4)); + } + catch (Exception e) + { + throw new RuntimeException("Unable to set taxi no", e); + } + } + + @Override + public String getFunctionName() + { + return "ORDER_TAXI"; + } + }); + + return functions; + } + + @Override + public List getGlobalIntents() + { + List globalIntents = new ArrayList(); + + globalIntents.add(new GlobalIntent("Stop", "stop")); + globalIntents.add(new GlobalIntent("Help", "help")); + + return globalIntents; + } + + // ... +} +``` + +Here is a snippet of the training data used to train the intents that the bot understands: + +``` +OrderTaxi Order me a cab +OrderTaxi Book a cab +OrderTaxi Order me a taxi +OrderTaxi Book a taxi +OrderTaxi Get me a cab +OrderTaxi Order a cab +OrderTaxi Get me a taxi +OrderTaxi Order a taxi +OrderTaxi Send a cab to 123 Mountain Drive +OrderTaxi Pick me up at 456 Queen Street +CancelTaxi Cancel my taxi +CancelTaxi Cancel my cab +CancelTaxi Cancel my order +WhereTaxi Where is my taxi ? +WhereTaxi Where is my cab ? +WhereTaxi How far away is my taxi ? +WhereTaxi How far away is my cab ? +WhereTaxi How long until my cab is here ? +WhereTaxi How long until my taxi is here ? +GaveAddress My address is 173 Essex Drive +GaveAddress The address is 1407 Graymalkin Lane , Salem Center +GaveAddress 52 Festive Road +GaveAddress it's 6151 Richmond Street +GaveAddress The pick up is at 740 Evergreen Terrace +GaveAddress 890 Fifth Avenue +GaveAddress The address is 13 China Ave +GaveAddress 136 River Road +``` + +And some of the training data to teach the bot how to identify street addresses: + +``` +Send a cab to 123 Mountain Drive + +The Whitehouse can be found at 1600 Pennsylvania Avenue . In the United Kingdom , the official residence of the Prime Minister can be found at 10 Downing Street . + +In New Zealand , The Beehive can be found in Molesworth St . + +Pick me up at 456 Queen Street + +Some other famous addresses include : 263 Prinsengracht , Amsterdam , the Home of Anne Frank in her diary and 1060 West Addison Street , Chigaco the location of Wrigley Field . + +In Seinfield , Newman lives at Apartment 5E , 129 West 81st Street . +``` + +Lastly here are some snippets from the Ink file that is used to manage the conversation: + +``` +== start == ++ [OrderTaxi] -> order_taxi ++ [CancelTaxi] -> cancel_taxi ++ [WhereTaxi] -> where_taxi + +VAR address="" +VAR taxiNo = "" + +== order_taxi == +- (order_taxi_loop) +{ + - address == "": -> request_address + - else: -> order_the_taxi +} + += request_address +What is the pick up address ? +::SET_REPROMPT Where would you like to be picked up ? +::SET_HINT 123 Someplace Rd ++ [GaveAddress] +- -> order_taxi_loop + += order_the_taxi +::ORDER_TAXI +Taxi {taxiNo} is on its way +::ADD_ATTACHMENT type::link url::http:\/\/trackcab.example.com/t/{taxiNo} title::Track your taxi here +::ADD_QUICK_REPLY Where is my taxi? +::ADD_QUICK_REPLY Cancel my taxi +-> END +``` + +## Usage +If you are using Gradle you can pull (for the example above) the latest release with: + +``` +repositories { + mavenCentral() +} + +dependencies { + compile 'com.rabidgremlin:mutters-ink-bot:5.0.0' + compile 'com.rabidgremlin:mutters-opennlp-intent:5.0.0' + compile 'com.rabidgremlin:mutters-opennlp-ner:5.0.0' + compile 'com.rabidgremlin:mutters-slots:5.0.0' +} +``` + + +Currently SNAPSHOT builds are available in the Sonatype OSSRH repository. + +Using Gradle you can pull the snapshot using: + +``` +repositories { + mavenCentral() + maven { + url "https://oss.sonatype.org/content/repositories/snapshots/" + } +} + +dependencies { + compile 'com.rabidgremlin:mutters-ink-bot:5.0.0-SNAPSHOT' + compile 'com.rabidgremlin:mutters-opennlp-intent:5.0.0-SNAPSHOT' + compile 'com.rabidgremlin:mutters-opennlp-ner:5.0.0-SNAPSHOT' + compile 'com.rabidgremlin:mutters-slots:5.0.0-SNAPSHOT' +} +``` + +## Packaging +Mutters is packaged into multiple jars to reduce dependencies and improve plugability. + +| Package | Description | +| ------------------------ | -------------------------------------------------------------------------------- | +| mutters-core | Contains core classes, interfaces and utility classes | +| mutters-fasttext-intent | Intent matcher that uses the fastText document classifier | +| mutters-ink-bot | Implementation of a Bot that uses Inkle's Ink engine for conversation scripting | +| mutters-opennlp-intent | Intent matcher that uses OpenNLP's document classifier | +| mutters-opennlp-ner | Slot matcher that uses OpenNLP's named entity recognition framework | +| mutters-slots | Implementation of a number of generic Slots | +| mutters-statemachine-bot | Implementation of a Bot that uses a state machine for conversation flows | +| mutters-templated-intent | Intent matcher that uses templates for matching | diff --git a/gradle.properties b/gradle.properties index e770685..5c1999a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ -version=4.4.2-SNAPSHOT - -# These are place holder values. Real values should be set in user's home gradle.properties -# and are only needed when signing and uploading to central maven repo - -ossrhUsername=null +version=5.0.0-SNAPSHOT + +# These are place holder values. Real values should be set in user's home gradle.properties +# and are only needed when signing and uploading to central maven repo + +ossrhUsername=null ossrhPassword=null \ No newline at end of file diff --git a/mutters-core/src/main/java/com/rabidgremlin/mutters/core/util/SessionUtils.java b/mutters-core/src/main/java/com/rabidgremlin/mutters/core/util/SessionUtils.java index bcb8cfb..76532fe 100644 --- a/mutters-core/src/main/java/com/rabidgremlin/mutters/core/util/SessionUtils.java +++ b/mutters-core/src/main/java/com/rabidgremlin/mutters/core/util/SessionUtils.java @@ -1,375 +1,375 @@ -package com.rabidgremlin.mutters.core.util; - -import org.joda.time.LocalDate; -import org.joda.time.LocalTime; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.rabidgremlin.mutters.core.IntentMatch; -import com.rabidgremlin.mutters.core.SlotMatch; -import com.rabidgremlin.mutters.core.session.Session; - -import java.util.List; - -/** - * This utility class provides methods working with a Session object. - * - * @author rabidgremlin - * - */ -public class SessionUtils -{ - /** Logger.*/ - private static final Logger LOG = LoggerFactory.getLogger(SessionUtils.class); - - /** Prefix for slot values stored in session to avoid any name collisions. */ - public static final String SLOT_PREFIX = "SLOT_JLA1974_"; - - protected SessionUtils() - { - // utility class - } - - /** - * Removes a stored slot value from a session. - * - * @param session The session. - * @param slotName The name of the slot. - */ - public static void removeSlotfromSession(Session session, String slotName) - { - session.removeAttribute(SLOT_PREFIX + slotName); - } - - /** - * Stores a reprompt string in a session. - * - * @param session The session. - * @param reprompt The reprompt string. - */ - public static void setReprompt(Session session, String reprompt) - { - session.setAttribute(SLOT_PREFIX + "0987654321REPROMPT1234567890", reprompt); - } - - /** - * Gets the current reprompt string from the session. - * - * @param session The session. - * @return The reprompt string or null if there is no reprompt string. - */ - public static String getReprompt(Session session) - { - return (String) session.getAttribute(SLOT_PREFIX + "0987654321REPROMPT1234567890"); - } - - /** - * Stores the reprompt quick replies in the session. - * - * @param session The session. - * @param repromptQuickReplies The reprompt quick replies. - */ - public static void setRepromptQuickReplies(Session session, List repromptQuickReplies) { - session.setAttribute(SLOT_PREFIX + "0987654321REPROMPTQUICKREPLIES1234567890", repromptQuickReplies); - } - - /** - * Gets the current reprompt quick replies from the session. - * - * @param session The session. - * @return The reprompt quick replies from the session. - */ - public static List getRepromptQuickReplies(Session session) - { - return (List) session.getAttribute(SLOT_PREFIX + "0987654321REPROMPTQUICKREPLIES1234567890"); - } - - /** - * Stores a reprompt hint string in a session. - * - * @param session The session. - * @param repromptHint The reprompt hint. - */ - public static void setRepromptHint(Session session, String repromptHint) - { - session.setAttribute(SLOT_PREFIX + "0987654321REPROMPTHINT1234567890", repromptHint); - } - - /** - * Gets the current reprompt hint from the session. - * - * @param session The session. - * @return The current reprompt hint or null if there is no reprompt hint. - */ - public static String getRepromptHint(Session session) - { - return (String) session.getAttribute(SLOT_PREFIX + "0987654321REPROMPTHINT1234567890"); - } - - /** - * Stores a Number slot value in the session. - * - * @param session The session. - * @param slotName The name of the slot. - * @param value The value to store. - */ - public static void setNumberSlotIntoSession(Session session, String slotName, Number value) - { - session.setAttribute(SLOT_PREFIX + slotName, value); - } - - /** - * Stores a String slot value in the session. - * - * @param session The session. - * @param slotName The name of the slot. - * @param value The value to store. - */ - public static void setStringSlotIntoSession(Session session, String slotName, String value) - { - session.setAttribute(SLOT_PREFIX + slotName, value); - } - - /** - * Stores a jodaTime LocalDate slot value in the session. - * - * @param session The session. - * @param slotName The name of the slot. - * @param value The value to store. - */ - public static void setLocalDateSlotIntoSession(Session session, String slotName, - LocalDate value) - { - session.setAttribute(SLOT_PREFIX + slotName, value); - } - - /** - * Stores a jodaTime LocalTime slot value in the session. - * - * @param session The session. - * @param slotName The name of the slot. - * @param value The value to store. - */ - public static void setLocalTimeSlotIntoSession(Session session, String slotName, - LocalTime value) - { - session.setAttribute(SLOT_PREFIX + slotName, value); - } - - /** - * Gets a String based slot value from an intent match. - * - * @param match The intent match to get the slot value from. - * @param slotName The name of the slot. - * @param defaultValue The default value to use if no slot found. - * @return The string value. - */ - public static String getStringSlot(IntentMatch match, String slotName, String defaultValue) - { - if (match.getSlotMatch(slotName) != null && match.getSlotMatch(slotName).getValue() != null) - { - try - { - return (String) match.getSlotMatch(slotName).getValue(); - } - catch(ClassCastException e) - { - // failed to cast so assume invalid string and return default - LOG.warn("Non String value: {} found in slot {}", match.getSlotMatch(slotName).getValue(), slotName); - return defaultValue; - } - } - else - { - return defaultValue; - } - } - - /** - * Gets a Number based slot value from an intent match. - * - * @param match The intent match to get the slot value from. - * @param slotName The name of the slot. - * @param defaultValue The default value to use if no slot found. - * @return The string value. - */ - public static Number getNumberSlot(IntentMatch match, String slotName, Number defaultValue) - { - if (match.getSlotMatch(slotName) != null && match.getSlotMatch(slotName).getValue() != null) - { - try - { - return (Number) match.getSlotMatch(slotName).getValue(); - } - catch(ClassCastException e) - { - // failed to cast so assume invalid number and return default - LOG.warn("Non Number value: {} found in slot {}", match.getSlotMatch(slotName).getValue(), slotName); - return defaultValue; - } - } - else - { - return defaultValue; - } - } - - /** - * Gets a jodaTime LocalDate based slot value from an intent match. - * - * @param match The intent match to get the slot value from. - * @param slotName The name of the slot. - * @param defaultValue The default value to use if no slot found. - * @return The local date value. - */ - public static LocalDate getLocalDateSlot(IntentMatch match, String slotName, - LocalDate defaultValue) - { - if (match.getSlotMatch(slotName) != null && match.getSlotMatch(slotName).getValue() != null) - { - try - { - return (LocalDate) match.getSlotMatch(slotName).getValue(); - } - catch(ClassCastException e) - { - // failed to cast so assume invalid localdate and return default - LOG.warn("Non LocalDate value: {} found in slot {}", match.getSlotMatch(slotName).getValue(), slotName); - return defaultValue; - } - } - else - { - return defaultValue; - } - } - - /** - * Gets a jodaTime LocalTime based slot value from an intent match. - * - * @param match The intent match to get the slot value from. - * @param slotName The name of the slot. - * @param defaultValue The default value to use if no slot found. - * @return The local time value. - */ - public static LocalTime getLocalTimeSlot(IntentMatch match, String slotName, - LocalTime defaultValue) - { - if (match.getSlotMatch(slotName) != null && match.getSlotMatch(slotName).getValue() != null) - { - try - { - return (LocalTime) match.getSlotMatch(slotName).getValue(); - } - catch(ClassCastException e) - { - // failed to cast so assume invalid localtime and return default - LOG.warn("Non LocalTime value: {} found in slot {}", match.getSlotMatch(slotName).getValue(), slotName); - return defaultValue; - } - } - else - { - return defaultValue; - } - } - - /** - * Saves all the matched slots for an IntentMatch into the session. - * - * @param match The intent match. - * @param session The session. - */ - public static void saveSlotsToSession(IntentMatch match, Session session) - { - for (SlotMatch slotMatch : match.getSlotMatches().values()) - { - session.setAttribute(SLOT_PREFIX + slotMatch.getSlot().getName(), slotMatch.getValue()); - } - } - - /** - * Gets a String value from the session (if it exists) or the slot (if a match exists). - * - * @param match The intent match. - * @param session The session. - * @param slotName The name of the slot. - * @param defaultValue The default value if not value found in the session or slot. - * @return The string value. - */ - public static String getStringFromSlotOrSession(IntentMatch match, Session session, - String slotName, String defaultValue) - { - String sessionValue = (String) session.getAttribute(SLOT_PREFIX + slotName); - if (sessionValue != null) - { - return sessionValue; - } - - return getStringSlot(match, slotName, defaultValue); - } - - /** - * Gets a Number value from the session (if it exists) or the slot (if a match exists). - * - * @param match The intent match. - * @param session The session. - * @param slotName The name of the slot. - * @param defaultValue The default value if not value found in the session or slot. - * @return The number value. - */ - public static Number getNumberFromSlotOrSession(IntentMatch match, Session session, - String slotName, Number defaultValue) - { - Number sessionValue = (Number) session.getAttribute(SLOT_PREFIX + slotName); - if (sessionValue != null) - { - return sessionValue; - } - - return getNumberSlot(match, slotName, defaultValue); - } - - /** - * Gets a jodaTime LocalDate value from the session (if it exists) or the slot (if a match exists). - * - * @param match The intent match. - * @param session The session. - * @param slotName The name of the slot. - * @param defaultValue The default value if not value found in the session or slot. - * @return The local date value. - */ - public static LocalDate getLocalDateFromSlotOrSession(IntentMatch match, Session session, - String slotName, LocalDate defaultValue) - { - LocalDate sessionValue = (LocalDate) session.getAttribute(SLOT_PREFIX + slotName); - if (sessionValue != null) - { - return sessionValue; - } - - return getLocalDateSlot(match, slotName, defaultValue); - } - - /** - * Gets a jodaTime LocalTime value from the session (if it exists) or the slot (if a match exists). - * - * @param match The intent match. - * @param session The session. - * @param slotName The name of the slot. - * @param defaultValue The default value if not value found in the session or slot. - * @return The local time value. - */ - public static LocalTime getLocalTimeFromSlotOrSession(IntentMatch match, Session session, - String slotName, LocalTime defaultValue) - { - LocalTime sessionValue = (LocalTime) session.getAttribute(SLOT_PREFIX + slotName); - if (sessionValue != null) - { - return sessionValue; - } - - return getLocalTimeSlot(match, slotName, defaultValue); - } - -} +package com.rabidgremlin.mutters.core.util; + +import org.joda.time.LocalDate; +import org.joda.time.LocalTime; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.rabidgremlin.mutters.core.IntentMatch; +import com.rabidgremlin.mutters.core.SlotMatch; +import com.rabidgremlin.mutters.core.session.Session; + +import java.util.List; + +/** + * This utility class provides methods working with a Session object. + * + * @author rabidgremlin + * + */ +public class SessionUtils +{ + /** Logger.*/ + private static final Logger LOG = LoggerFactory.getLogger(SessionUtils.class); + + /** Prefix for slot values stored in session to avoid any name collisions. */ + public static final String SLOT_PREFIX = "SLOT_JLA1974_"; + + protected SessionUtils() + { + // utility class + } + + /** + * Removes a stored slot value from a session. + * + * @param session The session. + * @param slotName The name of the slot. + */ + public static void removeSlotfromSession(Session session, String slotName) + { + session.removeAttribute(SLOT_PREFIX + slotName); + } + + /** + * Stores a reprompt string in a session. + * + * @param session The session. + * @param reprompt The reprompt string. + */ + public static void setReprompt(Session session, String reprompt) + { + session.setAttribute(SLOT_PREFIX + "0987654321REPROMPT1234567890", reprompt); + } + + /** + * Gets the current reprompt string from the session. + * + * @param session The session. + * @return The reprompt string or null if there is no reprompt string. + */ + public static String getReprompt(Session session) + { + return (String) session.getAttribute(SLOT_PREFIX + "0987654321REPROMPT1234567890"); + } + + /** + * Stores the reprompt quick replies in the session. + * + * @param session The session. + * @param repromptQuickReplies The reprompt quick replies. + */ + public static void setRepromptQuickReplies(Session session, List repromptQuickReplies) { + session.setAttribute(SLOT_PREFIX + "0987654321REPROMPTQUICKREPLIES1234567890", repromptQuickReplies); + } + + /** + * Gets the current reprompt quick replies from the session. + * + * @param session The session. + * @return The reprompt quick replies from the session. + */ + public static List getRepromptQuickReplies(Session session) + { + return (List) session.getAttribute(SLOT_PREFIX + "0987654321REPROMPTQUICKREPLIES1234567890"); + } + + /** + * Stores a reprompt hint string in a session. + * + * @param session The session. + * @param repromptHint The reprompt hint. + */ + public static void setRepromptHint(Session session, String repromptHint) + { + session.setAttribute(SLOT_PREFIX + "0987654321REPROMPTHINT1234567890", repromptHint); + } + + /** + * Gets the current reprompt hint from the session. + * + * @param session The session. + * @return The current reprompt hint or null if there is no reprompt hint. + */ + public static String getRepromptHint(Session session) + { + return (String) session.getAttribute(SLOT_PREFIX + "0987654321REPROMPTHINT1234567890"); + } + + /** + * Stores a Number slot value in the session. + * + * @param session The session. + * @param slotName The name of the slot. + * @param value The value to store. + */ + public static void setNumberSlotIntoSession(Session session, String slotName, Number value) + { + session.setAttribute(SLOT_PREFIX + slotName, value); + } + + /** + * Stores a String slot value in the session. + * + * @param session The session. + * @param slotName The name of the slot. + * @param value The value to store. + */ + public static void setStringSlotIntoSession(Session session, String slotName, String value) + { + session.setAttribute(SLOT_PREFIX + slotName, value); + } + + /** + * Stores a jodaTime LocalDate slot value in the session. + * + * @param session The session. + * @param slotName The name of the slot. + * @param value The value to store. + */ + public static void setLocalDateSlotIntoSession(Session session, String slotName, + LocalDate value) + { + session.setAttribute(SLOT_PREFIX + slotName, value); + } + + /** + * Stores a jodaTime LocalTime slot value in the session. + * + * @param session The session. + * @param slotName The name of the slot. + * @param value The value to store. + */ + public static void setLocalTimeSlotIntoSession(Session session, String slotName, + LocalTime value) + { + session.setAttribute(SLOT_PREFIX + slotName, value); + } + + /** + * Gets a String based slot value from an intent match. + * + * @param match The intent match to get the slot value from. + * @param slotName The name of the slot. + * @param defaultValue The default value to use if no slot found. + * @return The string value. + */ + public static String getStringSlot(IntentMatch match, String slotName, String defaultValue) + { + if (match.getSlotMatch(slotName) != null && match.getSlotMatch(slotName).getValue() != null) + { + try + { + return (String) match.getSlotMatch(slotName).getValue(); + } + catch(ClassCastException e) + { + // failed to cast so assume invalid string and return default + LOG.warn("Non String value: {} found in slot {}", match.getSlotMatch(slotName).getValue(), slotName); + return defaultValue; + } + } + else + { + return defaultValue; + } + } + + /** + * Gets a Number based slot value from an intent match. + * + * @param match The intent match to get the slot value from. + * @param slotName The name of the slot. + * @param defaultValue The default value to use if no slot found. + * @return The string value. + */ + public static Number getNumberSlot(IntentMatch match, String slotName, Number defaultValue) + { + if (match.getSlotMatch(slotName) != null && match.getSlotMatch(slotName).getValue() != null) + { + try + { + return (Number) match.getSlotMatch(slotName).getValue(); + } + catch(ClassCastException e) + { + // failed to cast so assume invalid number and return default + LOG.warn("Non Number value: {} found in slot {}", match.getSlotMatch(slotName).getValue(), slotName); + return defaultValue; + } + } + else + { + return defaultValue; + } + } + + /** + * Gets a jodaTime LocalDate based slot value from an intent match. + * + * @param match The intent match to get the slot value from. + * @param slotName The name of the slot. + * @param defaultValue The default value to use if no slot found. + * @return The local date value. + */ + public static LocalDate getLocalDateSlot(IntentMatch match, String slotName, + LocalDate defaultValue) + { + if (match.getSlotMatch(slotName) != null && match.getSlotMatch(slotName).getValue() != null) + { + try + { + return (LocalDate) match.getSlotMatch(slotName).getValue(); + } + catch(ClassCastException e) + { + // failed to cast so assume invalid localdate and return default + LOG.warn("Non LocalDate value: {} found in slot {}", match.getSlotMatch(slotName).getValue(), slotName); + return defaultValue; + } + } + else + { + return defaultValue; + } + } + + /** + * Gets a jodaTime LocalTime based slot value from an intent match. + * + * @param match The intent match to get the slot value from. + * @param slotName The name of the slot. + * @param defaultValue The default value to use if no slot found. + * @return The local time value. + */ + public static LocalTime getLocalTimeSlot(IntentMatch match, String slotName, + LocalTime defaultValue) + { + if (match.getSlotMatch(slotName) != null && match.getSlotMatch(slotName).getValue() != null) + { + try + { + return (LocalTime) match.getSlotMatch(slotName).getValue(); + } + catch(ClassCastException e) + { + // failed to cast so assume invalid localtime and return default + LOG.warn("Non LocalTime value: {} found in slot {}", match.getSlotMatch(slotName).getValue(), slotName); + return defaultValue; + } + } + else + { + return defaultValue; + } + } + + /** + * Saves all the matched slots for an IntentMatch into the session. + * + * @param match The intent match. + * @param session The session. + */ + public static void saveSlotsToSession(IntentMatch match, Session session) + { + for (SlotMatch slotMatch : match.getSlotMatches().values()) + { + session.setAttribute(SLOT_PREFIX + slotMatch.getSlot().getName(), slotMatch.getValue()); + } + } + + /** + * Gets a String value from the session (if it exists) or the slot (if a match exists). + * + * @param match The intent match. + * @param session The session. + * @param slotName The name of the slot. + * @param defaultValue The default value if not value found in the session or slot. + * @return The string value. + */ + public static String getStringFromSlotOrSession(IntentMatch match, Session session, + String slotName, String defaultValue) + { + String sessionValue = (String) session.getAttribute(SLOT_PREFIX + slotName); + if (sessionValue != null) + { + return sessionValue; + } + + return getStringSlot(match, slotName, defaultValue); + } + + /** + * Gets a Number value from the session (if it exists) or the slot (if a match exists). + * + * @param match The intent match. + * @param session The session. + * @param slotName The name of the slot. + * @param defaultValue The default value if not value found in the session or slot. + * @return The number value. + */ + public static Number getNumberFromSlotOrSession(IntentMatch match, Session session, + String slotName, Number defaultValue) + { + Number sessionValue = (Number) session.getAttribute(SLOT_PREFIX + slotName); + if (sessionValue != null) + { + return sessionValue; + } + + return getNumberSlot(match, slotName, defaultValue); + } + + /** + * Gets a jodaTime LocalDate value from the session (if it exists) or the slot (if a match exists). + * + * @param match The intent match. + * @param session The session. + * @param slotName The name of the slot. + * @param defaultValue The default value if not value found in the session or slot. + * @return The local date value. + */ + public static LocalDate getLocalDateFromSlotOrSession(IntentMatch match, Session session, + String slotName, LocalDate defaultValue) + { + LocalDate sessionValue = (LocalDate) session.getAttribute(SLOT_PREFIX + slotName); + if (sessionValue != null) + { + return sessionValue; + } + + return getLocalDateSlot(match, slotName, defaultValue); + } + + /** + * Gets a jodaTime LocalTime value from the session (if it exists) or the slot (if a match exists). + * + * @param match The intent match. + * @param session The session. + * @param slotName The name of the slot. + * @param defaultValue The default value if not value found in the session or slot. + * @return The local time value. + */ + public static LocalTime getLocalTimeFromSlotOrSession(IntentMatch match, Session session, + String slotName, LocalTime defaultValue) + { + LocalTime sessionValue = (LocalTime) session.getAttribute(SLOT_PREFIX + slotName); + if (sessionValue != null) + { + return sessionValue; + } + + return getLocalTimeSlot(match, slotName, defaultValue); + } + +} \ No newline at end of file diff --git a/mutters-ink-bot/build.gradle b/mutters-ink-bot/build.gradle index 3008296..b4971d8 100644 --- a/mutters-ink-bot/build.gradle +++ b/mutters-ink-bot/build.gradle @@ -15,7 +15,7 @@ repositories { dependencies { compile project(':mutters-core') - compile 'com.bladecoder.ink:blade-ink:0.4.3' + compile 'com.bladecoder.ink:blade-ink:0.6.0' compile 'commons-io:commons-io:2.5' compile 'org.slf4j:slf4j-api:1.7.21' compile 'org.apache.commons:commons-lang3:3.4' diff --git a/mutters-ink-bot/src/main/java/com/rabidgremlin/mutters/bot/ink/InkBot.java b/mutters-ink-bot/src/main/java/com/rabidgremlin/mutters/bot/ink/InkBot.java index ce295bf..85ed77c 100644 --- a/mutters-ink-bot/src/main/java/com/rabidgremlin/mutters/bot/ink/InkBot.java +++ b/mutters-ink-bot/src/main/java/com/rabidgremlin/mutters/bot/ink/InkBot.java @@ -1,552 +1,549 @@ -package com.rabidgremlin.mutters.bot.ink; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Random; -import java.util.Collection; - -import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.bladecoder.ink.runtime.Choice; -import com.bladecoder.ink.runtime.Story; -import com.bladecoder.ink.runtime.StoryException; -import com.rabidgremlin.mutters.bot.ink.InkBotConfiguration.ConfusedKnot; -import com.rabidgremlin.mutters.bot.ink.InkBotConfiguration.GlobalIntent; -import com.rabidgremlin.mutters.bot.ink.functions.AddAttachmentFunction; -import com.rabidgremlin.mutters.bot.ink.functions.AddQuickReplyFunction; -import com.rabidgremlin.mutters.bot.ink.functions.GetLongTermAttributeFunction; -import com.rabidgremlin.mutters.bot.ink.functions.RemoveLongTermAttributeFunction; -import com.rabidgremlin.mutters.bot.ink.functions.SetHintFunction; -import com.rabidgremlin.mutters.bot.ink.functions.SetLongTermAttributeFunction; -import com.rabidgremlin.mutters.bot.ink.functions.SetRepromptFunction; -import com.rabidgremlin.mutters.core.Context; -import com.rabidgremlin.mutters.core.IntentMatch; -import com.rabidgremlin.mutters.core.IntentMatcher; -import com.rabidgremlin.mutters.core.SlotMatch; -import com.rabidgremlin.mutters.core.bot.Bot; -import com.rabidgremlin.mutters.core.bot.BotException; -import com.rabidgremlin.mutters.core.bot.BotResponse; -import com.rabidgremlin.mutters.core.session.Session; - -/** - * This is the base bot class for bots using the Ink narrative scripting language from Inkle. The bot requires a - * compiled ink file in .json format. The choices in the ink file should match the names of intents returned by the - * IntentMatcher. - * - * See http://www.inklestudios.com/ink/ for more info on Ink - * - * This class also adds the ADD_ATTACHMENT, ADD_QUICK_REPLY, SET_HINT, SET_REPROMPT functions to the bot. - * - * @see com.rabidgremlin.mutters.bot.ink.functions.AddAttachmentFunction - * @see com.rabidgremlin.mutters.bot.ink.functions.AddQuickReplyFunction - * @see com.rabidgremlin.mutters.bot.ink.functions.SetHintFunction - * @see com.rabidgremlin.mutters.bot.ink.functions.SetRepromptFunction - * - * @author rabidgremlin - * - */ -public abstract class InkBot - implements Bot -{ - /** Logger for the bot. */ - private Logger log = LoggerFactory.getLogger(InkBot.class); - - /** The intent matcher for the bot. */ - protected IntentMatcher matcher; - - /** The ink JSON for the bot. */ - protected String inkStoryJson; - - /** Default responses for when the bot cannot figure out what was said to it. */ - protected String[] defaultResponses = { "Pardon?" }; - - /** Map of InkBotFunctions the bot knows. */ - protected HashMap inkBotFunctions = new HashMap(); - - /** Map of global intents for the bot. */ - protected HashMap globalIntents = new HashMap<>(); - - /** Random for default reponses. */ - private Random rand = new Random(); - - /** Debug value key for matched intent. */ - public final static String DK_MATCHED_INTENT = "matchedIntent"; - - /** Debug value key for intent matching scores. */ - public final static String DK_INTENT_MATCHING_SCORES = "intentMatchingScores"; - - /** The number of failed to understand attempts before bot is confused. */ - private int maxAttemptsBeforeConfused = -1; - - /** The name of the ink knot to jump too when the bot is confused. */ - private String confusedKnotName = null; - - /** A thread local map that holds the story instance for each unique story JSON */ - private static ThreadLocal> threadLocalStoryMap = ThreadLocal.withInitial(HashMap::new); - - /** - * Constructs the bot. - * - * @param configuration The InkBotConfiguration object for the bot. - * - */ - public InkBot(T configuration) - { - // get the matcher set up - matcher = configuration.getIntentMatcher(); - - // get the story json - inkStoryJson = configuration.getStoryJson(); - - // Add default functions - addFunction(new SetHintFunction()); - addFunction(new SetRepromptFunction()); - addFunction(new AddAttachmentFunction()); - addFunction(new AddQuickReplyFunction()); - addFunction(new SetLongTermAttributeFunction()); - addFunction(new GetLongTermAttributeFunction()); - addFunction(new RemoveLongTermAttributeFunction()); - - // add any other functions for the bot - List functions = configuration.getInkFunctions(); - if (functions != null) - { - for (InkBotFunction function : functions) - { - addFunction(function); - } - } - - // add any any global intents - List globalIntents = configuration.getGlobalIntents(); - if (globalIntents != null) - { - for (GlobalIntent globalIntent : globalIntents) - { - addGlobalIntent(globalIntent.getIntentName(), globalIntent.getKnotName()); - } - } - - // set up confused knot if supplied - ConfusedKnot confusedKnot = configuration.getConfusedKnot(); - if (confusedKnot != null) - { - setConfusedKnot(confusedKnot.getMaxAttemptsBeforeConfused(), confusedKnot.getConfusedKnotName()); - } - - // set up default phrases if supplied - List defaultResponses = configuration.getDefaultResponses(); - if (defaultResponses != null) - { - setDefaultResponses(defaultResponses.toArray(new String[0])); - } - } - - /* - * (non-Javadoc) - * - * @see com.rabidgremlin.mutters.bot.Bot#respond(com.rabidgremlin.mutters.session.Session, - * com.rabidgremlin.mutters.core.Context, java.lang.String) - */ - @Override - public BotResponse respond(Session session, Context context, String messageText) - throws BotException - { - log.debug("===> \n session: {} context: {} messageText: {}", - new Object[]{ session, context, messageText }); - - CurrentResponse currentResponse = new CurrentResponse(); - - // choose a default response - String defaultResponse = defaultResponses[rand.nextInt(defaultResponses.length)]; - - // set up default response in case bot has issue processing input - currentResponse.setResponseText(SessionUtils.getReprompt(session)); - if (currentResponse.getResponseText() == null) - { - currentResponse.setResponseText(defaultResponse); - } - - // preserve hint if we had reprompt hint - currentResponse.setHint(SessionUtils.getRepromptHint(session)); - - // preserve quick replies if we had them - currentResponse.setResponseQuickReplies(SessionUtils.getRepromptQuickReplies(session)); - - // keep hold of matched intent for logging and debug - String matchedIntent = null; - - int failedToUnderstandCount = SessionUtils.getFailedToUnderstandCount(session); - log.debug("current failed count is {}", failedToUnderstandCount); - - try - { - Story story = threadLocalStoryMap.get().get(inkStoryJson); - - if (story == null) - { - synchronized (this) - { - // wrap create in synchronized block because something in JSON parsing is not threadsafe - story = new StoryDecorator(inkStoryJson); - threadLocalStoryMap.get().put(inkStoryJson, story); - } - } - else - { - story.resetState(); - } - - // call hook so externs and other things can be applied - afterStoryCreated(story); - - // restore the story state - SessionUtils.loadInkStoryState(session, story.getState()); - - // call hook so additional things can be applied to story after state has been restored - afterStoryStateLoaded(story); - - // get to right place in story, capture any pretext - String preText = processStory(session, currentResponse, story, null, false).toString(); - - // build expected intents set - HashSet expectedIntents = new HashSet(); - // add all the names of the global intents - expectedIntents.addAll(globalIntents.keySet()); - // add all the choices - for (Choice choice : story.getCurrentChoices()) - { - expectedIntents.add(choice.getText()); - } - - // create debug values map - HashMap debugValues = new HashMap(); - - // match the intents - IntentMatch intentMatch = matcher.match(messageText, context, expectedIntents, debugValues); - - if (intentMatch != null) - { - // record name of intent we matched on - matchedIntent = intentMatch.getIntent().getName(); - - // call after match hook, allows fixups to be applied - afterIntentMatch(intentMatch, session, story); - - // copy any slot values into ink vars - setSlotValuesInInk(intentMatch.getSlotMatches().values(), story); - - // did we match something flag. Used so we can set reprompt correctly - boolean foundMatch = false; - - // check if this is a global intent - String knotName = globalIntents.get(intentMatch.getIntent().getName()); - - // if global intent then jump to knot, otherwise pick choice - if (knotName != null) - { - story.choosePathString(knotName); - getResponseText(session, currentResponse, story, intentMatch, false, preText); - foundMatch = true; - } - else - { - // loop through choices find the one that matches intent - if (story.getCurrentChoices().size() > 0) - { - int choiceIndex = 0; - for (Choice c : story.getCurrentChoices()) - { - log.debug("Checking choice: {}", c.getText()); - if (StringUtils.equalsIgnoreCase(intentMatch.getIntent().getName(), c.getText())) - { - log.debug("Choosing: {}", c.getText()); - story.chooseChoiceIndex(choiceIndex); - - getResponseText(session, currentResponse, story, intentMatch, true, preText); - - foundMatch = true; - break; - } - choiceIndex++; - } - } - } - - // did we match to global or choice ? - if (foundMatch) - { - // reset failed count - failedToUnderstandCount = 0; - - setRepromptInSession(currentResponse, session, defaultResponse); - } - else - { - // found intent but did not match global or choice so increment fail count - failedToUnderstandCount += 1; - } - } - else - { - // did not find intent so increment fail account - failedToUnderstandCount += 1; - } - - log.debug("failed count is now {}", failedToUnderstandCount); - - // do we have confused knot and failed attempt > max failed attempts ? - if (confusedKnotName != null && failedToUnderstandCount >= maxAttemptsBeforeConfused) - { - log.debug("Bot is confused. failedToUnderstandCount({}) >= maxAttemptsBeforeConfused ({})", failedToUnderstandCount, maxAttemptsBeforeConfused); - log.debug("jumping to {} ", confusedKnotName); - // jump to confused knot - story.choosePathString(confusedKnotName); - // continue story - getResponseText(session, currentResponse, story, intentMatch, false, preText); - // reset failed count - failedToUnderstandCount = 0; - - setRepromptInSession(currentResponse, session, defaultResponse); - } - - // save failed count - SessionUtils.setFailedToUnderstandCount(session, failedToUnderstandCount); - - // save current story state - SessionUtils.saveInkStoryState(session, story.getState()); - - // does story have any more choices ? - if (story.getCurrentChoices().size() == 0) - { - // no, conversation is done, wipe session and we are not returning an ask response - session.reset(); - currentResponse.setAskResponse(false); - } - - // populate debug values map with matched intent - if (matchedIntent != null) - { - debugValues.put(DK_MATCHED_INTENT, matchedIntent); - } - - // build and return response - return new BotResponse(currentResponse.getResponseText(), currentResponse.getHint(), currentResponse.isAskResponse(), - currentResponse.getResponseAttachments(), - currentResponse.getResponseQuickReplies(), debugValues); - } - catch (Exception e) - { - throw new BotException("Unexpected error", e); - } - } - - private void setRepromptInSession(CurrentResponse currentResponse, Session session, String defaultResponse) - { - if (currentResponse.getReprompt() != null) - { - SessionUtils.setReprompt(session, currentResponse.getReprompt()); - } - else - { - SessionUtils.setReprompt(session, defaultResponse + " " + currentResponse.getResponseText()); - } - SessionUtils.setRepromptHint(session, currentResponse.getHint()); - SessionUtils.setRepromptQuickReplies(session, currentResponse.getResponseQuickReplies()); - } - - private void setSlotValuesInInk(Collection slotMatches, Story story) throws Exception - { - for (SlotMatch slotMatch : slotMatches) - { - if (slotMatch.getValue() instanceof Number) - { - story.getVariablesState().set(slotMatch.getSlot().getName().toLowerCase(), slotMatch.getValue()); - } - else - { - story.getVariablesState().set(slotMatch.getSlot().getName().toLowerCase(), slotMatch.getValue().toString()); - } - } - } - - private void getResponseText(Session session, CurrentResponse currentResponse, Story story, IntentMatch intentMatch, boolean skipfirst, String preText) - throws StoryException, Exception - { - // reset reprompt, hint and quick replies - currentResponse.setReprompt(null); - currentResponse.setHint(null); - currentResponse.setResponseQuickReplies(null); - - // get the story output and build the reponse - StringBuffer response = processStory(session, currentResponse, story, intentMatch, skipfirst); - - // add any pretext if we have it - preText = StringUtils.chomp(preText).trim(); // remove any trailing \n and trim to ensure we actually have some content - if (StringUtils.isNotBlank(preText)) - { - response.insert(0,"\n"); - response.insert(0,preText); - } - - currentResponse.setResponseText(response.toString()); - } - - /** Processes the story until the next set of choices, triggering any InkFunctions along the way. - * - * @param session The current session. - * @param currentResponse The current response. - * @param story The current story. - * @param intentMatch The current intent match. - * @param skipfirst True if first lien should be skipped. Required as ink always replays choice. - * @return String buffer containing output. - * @throws StoryException Thrown if there is an error. - * @throws Exception Thrown if there is an error. - */ - private StringBuffer processStory(Session session, CurrentResponse currentResponse, Story story, IntentMatch intentMatch, boolean skipfirst) throws StoryException, Exception - { - StringBuffer response = new StringBuffer(); - - boolean first = true; - while (story.canContinue()) - { - String line = story.Continue(); - - // skip first line as ink replays choice first - if (first && skipfirst) - { - first = false; - continue; - } - - processStoryLine(line,response,currentResponse, session, intentMatch, story); - } - - // chop off last \n - if (response.length() > 0 && response.charAt(response.length() - 1) == '\n') - { - response.setLength(response.length() - 1); - } - - return response; - } - - /** Processes a story line triggering any InkFunctions that are found. - * - * @param line The story line to process. - * @param response The response to populate. - * @param currentResponse The current response. - * @param session The current session. - * @param intentMatch The current intent match. - * @param story The current story. - */ - private void processStoryLine(String line, StringBuffer response, CurrentResponse currentResponse, Session session, IntentMatch intentMatch, Story story) - { - log.debug("Line {}", line); - - String trimmedLine = line.trim(); - - if (trimmedLine.startsWith("::")) - { - String functionName = trimmedLine.split(" ")[0].substring(2).trim(); - String param = trimmedLine.substring(functionName.length() + 2).trim(); - - InkBotFunction function = inkBotFunctions.get(functionName.toLowerCase()); - if (function != null) - { - function.execute(currentResponse, session, intentMatch, story, param); - } - else - { - log.warn("Did not find function named {}", functionName); - } - } - else - { - response.append(line); - } - } - - /** - * Sets the default response for the bot. This is the bot's response if it doesn't understand what was said. - * - * @param defaultResponses The new default bot responses. - */ - private void setDefaultResponses(String[] defaultResponses) - { - this.defaultResponses = defaultResponses; - } - - /** - * Adds a InkBotFunction to the bot. - * - * @param function The function to add. - */ - private void addFunction(InkBotFunction function) - { - inkBotFunctions.put(function.getFunctionName().toLowerCase(), function); - } - - /** - * This method can be overridden to manipulate the Story object used by the bot just after it is created. Note the bot - * may create the story multiple times. This method is useful for registering external functions with the Ink runtime. - * - * @param story The just created story. - */ - protected void afterStoryCreated(Story story) - { - // do nothing - } - - /** - * This method can be overridden to manipulate the Story object used by the bot just after the story state has been - * loaded from the session. This method is useful for setting story variables based on external data. - * - * @param story The story whose state has just been loaded. - */ - protected void afterStoryStateLoaded(Story story) - { - // do nothing - } - - /** - * This method can be overridden to manipulate the results of an intent match. It allows the match to be manipulated - * before the class uses it to progress the ink story. - * - * @param intentMatch The intent match. - * @param session The current user's session. - * @param story The current story. - */ - protected void afterIntentMatch(IntentMatch intentMatch, Session session, Story story) - { - // do nothing - } - - /** - * Adds a global intent to the list of global intents for the bot. - * - * @param intentName The name of the intent. - * @param knotName The name of the knot to jump to when intent is triggered. - */ - private void addGlobalIntent(String intentName, String knotName) - { - globalIntents.put(intentName, knotName); - } - - /** - * Sets the confused knot for the bot. - * - * @param maxAttemptsBeforeConfused The number of failed attempts before the but is confused. - * @param confusedKnotName The name of the knot to jump too when the bot is confused. - */ - private void setConfusedKnot(int maxAttemptsBeforeConfused, String confusedKnotName) - { - this.maxAttemptsBeforeConfused = maxAttemptsBeforeConfused; - this.confusedKnotName = confusedKnotName; - } - -} +package com.rabidgremlin.mutters.bot.ink; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.bladecoder.ink.runtime.Choice; +import com.bladecoder.ink.runtime.Story; +import com.bladecoder.ink.runtime.StoryException; +import com.rabidgremlin.mutters.bot.ink.InkBotConfiguration.ConfusedKnot; +import com.rabidgremlin.mutters.bot.ink.InkBotConfiguration.GlobalIntent; +import com.rabidgremlin.mutters.bot.ink.functions.AddAttachmentFunction; +import com.rabidgremlin.mutters.bot.ink.functions.AddQuickReplyFunction; +import com.rabidgremlin.mutters.bot.ink.functions.GetLongTermAttributeFunction; +import com.rabidgremlin.mutters.bot.ink.functions.RemoveLongTermAttributeFunction; +import com.rabidgremlin.mutters.bot.ink.functions.SetHintFunction; +import com.rabidgremlin.mutters.bot.ink.functions.SetLongTermAttributeFunction; +import com.rabidgremlin.mutters.bot.ink.functions.SetRepromptFunction; +import com.rabidgremlin.mutters.core.Context; +import com.rabidgremlin.mutters.core.IntentMatch; +import com.rabidgremlin.mutters.core.IntentMatcher; +import com.rabidgremlin.mutters.core.SlotMatch; +import com.rabidgremlin.mutters.core.bot.Bot; +import com.rabidgremlin.mutters.core.bot.BotException; +import com.rabidgremlin.mutters.core.bot.BotResponse; +import com.rabidgremlin.mutters.core.session.Session; + +/** + * This is the base bot class for bots using the Ink narrative scripting language from Inkle. The bot requires a + * compiled ink file in .json format. The choices in the ink file should match the names of intents returned by the + * IntentMatcher. + * + * See http://www.inklestudios.com/ink/ for more info on Ink + * + * This class also adds the ADD_ATTACHMENT, ADD_QUICK_REPLY, SET_HINT, SET_REPROMPT functions to the bot. + * + * @see com.rabidgremlin.mutters.bot.ink.functions.AddAttachmentFunction + * @see com.rabidgremlin.mutters.bot.ink.functions.AddQuickReplyFunction + * @see com.rabidgremlin.mutters.bot.ink.functions.SetHintFunction + * @see com.rabidgremlin.mutters.bot.ink.functions.SetRepromptFunction + * + * @author rabidgremlin + * + */ +public abstract class InkBot + implements Bot +{ + /** Logger for the bot. */ + private Logger log = LoggerFactory.getLogger(InkBot.class); + + /** The intent matcher for the bot. */ + protected IntentMatcher matcher; + + /** The ink JSON for the bot. */ + protected String inkStoryJson; + + /** Default responses for when the bot cannot figure out what was said to it. */ + protected String[] defaultResponses = { "Pardon?" }; + + /** Map of InkBotFunctions the bot knows. */ + protected HashMap inkBotFunctions = new HashMap(); + + /** Map of global intents for the bot. */ + protected HashMap globalIntents = new HashMap<>(); + + /** Random for default reponses. */ + private Random rand = new Random(); + + /** Debug value key for matched intent. */ + public final static String DK_MATCHED_INTENT = "matchedIntent"; + + /** Debug value key for intent matching scores. */ + public final static String DK_INTENT_MATCHING_SCORES = "intentMatchingScores"; + + /** The number of failed to understand attempts before bot is confused. */ + private int maxAttemptsBeforeConfused = -1; + + /** The name of the ink knot to jump too when the bot is confused. */ + private String confusedKnotName = null; + + /** A thread local map that holds the story instance for each unique story JSON */ + private static ThreadLocal> threadLocalStoryMap = ThreadLocal.withInitial(HashMap::new); + + /** + * Constructs the bot. + * + * @param configuration The InkBotConfiguration object for the bot. + * + */ + public InkBot(T configuration) + { + // get the matcher set up + matcher = configuration.getIntentMatcher(); + + // get the story json + inkStoryJson = configuration.getStoryJson(); + + // Add default functions + addFunction(new SetHintFunction()); + addFunction(new SetRepromptFunction()); + addFunction(new AddAttachmentFunction()); + addFunction(new AddQuickReplyFunction()); + addFunction(new SetLongTermAttributeFunction()); + addFunction(new GetLongTermAttributeFunction()); + addFunction(new RemoveLongTermAttributeFunction()); + + // add any other functions for the bot + List functions = configuration.getInkFunctions(); + if (functions != null) + { + for (InkBotFunction function : functions) + { + addFunction(function); + } + } + + // add any any global intents + List globalIntents = configuration.getGlobalIntents(); + if (globalIntents != null) + { + for (GlobalIntent globalIntent : globalIntents) + { + addGlobalIntent(globalIntent.getIntentName(), globalIntent.getKnotName()); + } + } + + // set up confused knot if supplied + ConfusedKnot confusedKnot = configuration.getConfusedKnot(); + if (confusedKnot != null) + { + setConfusedKnot(confusedKnot.getMaxAttemptsBeforeConfused(), confusedKnot.getConfusedKnotName()); + } + + // set up default phrases if supplied + List defaultResponses = configuration.getDefaultResponses(); + if (defaultResponses != null) + { + setDefaultResponses(defaultResponses.toArray(new String[0])); + } + } + + /* + * (non-Javadoc) + * + * @see com.rabidgremlin.mutters.bot.Bot#respond(com.rabidgremlin.mutters.session.Session, + * com.rabidgremlin.mutters.core.Context, java.lang.String) + */ + @Override + public BotResponse respond(Session session, Context context, String messageText) + throws BotException + { + log.debug("===> \n session: {} context: {} messageText: {}", + new Object[]{ session, context, messageText }); + + CurrentResponse currentResponse = new CurrentResponse(); + + // choose a default response + String defaultResponse = defaultResponses[rand.nextInt(defaultResponses.length)]; + + // set up default response in case bot has issue processing input + currentResponse.setResponseText(SessionUtils.getReprompt(session)); + if (currentResponse.getResponseText() == null) + { + currentResponse.setResponseText(defaultResponse); + } + + // preserve hint if we had reprompt hint + currentResponse.setHint(SessionUtils.getRepromptHint(session)); + + // preserve quick replies if we had them + currentResponse.setResponseQuickReplies(SessionUtils.getRepromptQuickReplies(session)); + + // keep hold of matched intent for logging and debug + String matchedIntent = null; + + int failedToUnderstandCount = SessionUtils.getFailedToUnderstandCount(session); + log.debug("current failed count is {}", failedToUnderstandCount); + + try + { + Story story = threadLocalStoryMap.get().get(inkStoryJson); + + if (story == null) + { + synchronized (this) + { + // wrap create in synchronized block because something in JSON parsing is not threadsafe + story = new StoryDecorator(inkStoryJson); + threadLocalStoryMap.get().put(inkStoryJson, story); + } + } + else + { + story.resetState(); + } + + // call hook so externs and other things can be applied + afterStoryCreated(story); + + // restore the story state + SessionUtils.loadInkStoryState(session, story.getState()); + + // call hook so additional things can be applied to story after state has been restored + afterStoryStateLoaded(story); + + // get to right place in story, capture any pretext + String preText = processStory(session, currentResponse, story, null).toString(); + + // build expected intents set + HashSet expectedIntents = new HashSet(); + // add all the names of the global intents + expectedIntents.addAll(globalIntents.keySet()); + // add all the choices + for (Choice choice : story.getCurrentChoices()) + { + expectedIntents.add(choice.getText()); + } + + // create debug values map + HashMap debugValues = new HashMap(); + + // match the intents + IntentMatch intentMatch = matcher.match(messageText, context, expectedIntents, debugValues); + + if (intentMatch != null) + { + // record name of intent we matched on + matchedIntent = intentMatch.getIntent().getName(); + + // call after match hook, allows fixups to be applied + afterIntentMatch(intentMatch, session, story); + + // copy any slot values into ink vars + setSlotValuesInInk(intentMatch.getSlotMatches().values(), story); + + // did we match something flag. Used so we can set reprompt correctly + boolean foundMatch = false; + + // check if this is a global intent + String knotName = globalIntents.get(intentMatch.getIntent().getName()); + + // if global intent then jump to knot, otherwise pick choice + if (knotName != null) + { + story.choosePathString(knotName); + getResponseText(session, currentResponse, story, intentMatch, preText); + foundMatch = true; + } + else + { + // loop through choices find the one that matches intent + if (story.getCurrentChoices().size() > 0) + { + int choiceIndex = 0; + for (Choice c : story.getCurrentChoices()) + { + log.debug("Checking choice: {}", c.getText()); + if (StringUtils.equalsIgnoreCase(intentMatch.getIntent().getName(), c.getText())) + { + log.debug("Choosing: {}", c.getText()); + story.chooseChoiceIndex(choiceIndex); + + getResponseText(session, currentResponse, story, intentMatch, preText); + + foundMatch = true; + break; + } + choiceIndex++; + } + } + } + + // did we match to global or choice ? + if (foundMatch) + { + // reset failed count + failedToUnderstandCount = 0; + + setRepromptInSession(currentResponse, session, defaultResponse); + } + else + { + // found intent but did not match global or choice so increment fail count + failedToUnderstandCount += 1; + } + } + else + { + // did not find intent so increment fail account + failedToUnderstandCount += 1; + } + + log.debug("failed count is now {}", failedToUnderstandCount); + + // do we have confused knot and failed attempt > max failed attempts ? + if (confusedKnotName != null && failedToUnderstandCount >= maxAttemptsBeforeConfused) + { + log.debug("Bot is confused. failedToUnderstandCount({}) >= maxAttemptsBeforeConfused ({})", failedToUnderstandCount, maxAttemptsBeforeConfused); + log.debug("jumping to {} ", confusedKnotName); + // jump to confused knot + story.choosePathString(confusedKnotName); + // continue story + getResponseText(session, currentResponse, story, intentMatch, preText); + // reset failed count + failedToUnderstandCount = 0; + + setRepromptInSession(currentResponse, session, defaultResponse); + } + + // save failed count + SessionUtils.setFailedToUnderstandCount(session, failedToUnderstandCount); + + // save current story state + SessionUtils.saveInkStoryState(session, story.getState()); + + // does story have any more choices ? + if (story.getCurrentChoices().size() == 0) + { + // no, conversation is done, wipe session and we are not returning an ask response + session.reset(); + currentResponse.setAskResponse(false); + } + + // populate debug values map with matched intent + if (matchedIntent != null) + { + debugValues.put(DK_MATCHED_INTENT, matchedIntent); + } + + // build and return response + return new BotResponse(currentResponse.getResponseText(), currentResponse.getHint(), currentResponse.isAskResponse(), + currentResponse.getResponseAttachments(), + currentResponse.getResponseQuickReplies(), debugValues); + } + catch (Exception e) + { + throw new BotException("Unexpected error", e); + } + } + + private void setRepromptInSession(CurrentResponse currentResponse, Session session, String defaultResponse) + { + if (currentResponse.getReprompt() != null) + { + SessionUtils.setReprompt(session, currentResponse.getReprompt()); + } + else + { + SessionUtils.setReprompt(session, defaultResponse + " " + currentResponse.getResponseText()); + } + SessionUtils.setRepromptHint(session, currentResponse.getHint()); + SessionUtils.setRepromptQuickReplies(session, currentResponse.getResponseQuickReplies()); + } + + private void setSlotValuesInInk(Collection slotMatches, Story story) throws Exception + { + for (SlotMatch slotMatch : slotMatches) + { + if (slotMatch.getValue() instanceof Number) + { + story.getVariablesState().set(slotMatch.getSlot().getName().toLowerCase(), slotMatch.getValue()); + } + else + { + story.getVariablesState().set(slotMatch.getSlot().getName().toLowerCase(), slotMatch.getValue().toString()); + } + } + } + + private void getResponseText(Session session, CurrentResponse currentResponse, Story story, IntentMatch intentMatch, String preText) + throws StoryException, Exception + { + // reset reprompt, hint and quick replies + currentResponse.setReprompt(null); + currentResponse.setHint(null); + currentResponse.setResponseQuickReplies(null); + + // get the story output and build the reponse + StringBuffer response = processStory(session, currentResponse, story, intentMatch); + + // add any pretext if we have it + preText = StringUtils.chomp(preText).trim(); // remove any trailing \n and trim to ensure we actually have some content + if (StringUtils.isNotBlank(preText)) + { + response.insert(0,"\n"); + response.insert(0,preText); + } + + // strip any leading \n deals with some ink inconsistencies such as in switch statements + if (response.charAt(0) == '\n') + { + response.deleteCharAt(0); + } + + currentResponse.setResponseText(response.toString()); + } + + /** Processes the story until the next set of choices, triggering any InkFunctions along the way. + * + * @param session The current session. + * @param currentResponse The current response. + * @param story The current story. + * @param intentMatch The current intent match. + * @return String buffer containing output. + * @throws StoryException Thrown if there is an error. + * @throws Exception Thrown if there is an error. + */ + private StringBuffer processStory(Session session, CurrentResponse currentResponse, Story story, IntentMatch intentMatch) throws StoryException, Exception + { + StringBuffer response = new StringBuffer(); + + + while (story.canContinue()) + { + String line = story.Continue(); + processStoryLine(line,response,currentResponse, session, intentMatch, story); + } + + // chop off last \n + if (response.length() > 0 && response.charAt(response.length() - 1) == '\n') + { + response.setLength(response.length() - 1); + } + + return response; + } + + /** Processes a story line triggering any InkFunctions that are found. + * + * @param line The story line to process. + * @param response The response to populate. + * @param currentResponse The current response. + * @param session The current session. + * @param intentMatch The current intent match. + * @param story The current story. + */ + private void processStoryLine(String line, StringBuffer response, CurrentResponse currentResponse, Session session, IntentMatch intentMatch, Story story) + { + log.debug("Line {}", line); + + String trimmedLine = line.trim(); + + if (trimmedLine.startsWith("::")) + { + String functionName = trimmedLine.split(" ")[0].substring(2).trim(); + String param = trimmedLine.substring(functionName.length() + 2).trim(); + + InkBotFunction function = inkBotFunctions.get(functionName.toLowerCase()); + if (function != null) + { + function.execute(currentResponse, session, intentMatch, story, param); + } + else + { + log.warn("Did not find function named {}", functionName); + } + } + else + { + response.append(line); + } + } + + /** + * Sets the default response for the bot. This is the bot's response if it doesn't understand what was said. + * + * @param defaultResponses The new default bot responses. + */ + private void setDefaultResponses(String[] defaultResponses) + { + this.defaultResponses = defaultResponses; + } + + /** + * Adds a InkBotFunction to the bot. + * + * @param function The function to add. + */ + private void addFunction(InkBotFunction function) + { + inkBotFunctions.put(function.getFunctionName().toLowerCase(), function); + } + + /** + * This method can be overridden to manipulate the Story object used by the bot just after it is created. Note the bot + * may create the story multiple times. This method is useful for registering external functions with the Ink runtime. + * + * @param story The just created story. + */ + protected void afterStoryCreated(Story story) + { + // do nothing + } + + /** + * This method can be overridden to manipulate the Story object used by the bot just after the story state has been + * loaded from the session. This method is useful for setting story variables based on external data. + * + * @param story The story whose state has just been loaded. + */ + protected void afterStoryStateLoaded(Story story) + { + // do nothing + } + + /** + * This method can be overridden to manipulate the results of an intent match. It allows the match to be manipulated + * before the class uses it to progress the ink story. + * + * @param intentMatch The intent match. + * @param session The current user's session. + * @param story The current story. + */ + protected void afterIntentMatch(IntentMatch intentMatch, Session session, Story story) + { + // do nothing + } + + /** + * Adds a global intent to the list of global intents for the bot. + * + * @param intentName The name of the intent. + * @param knotName The name of the knot to jump to when intent is triggered. + */ + private void addGlobalIntent(String intentName, String knotName) + { + globalIntents.put(intentName, knotName); + } + + /** + * Sets the confused knot for the bot. + * + * @param maxAttemptsBeforeConfused The number of failed attempts before the but is confused. + * @param confusedKnotName The name of the knot to jump too when the bot is confused. + */ + private void setConfusedKnot(int maxAttemptsBeforeConfused, String confusedKnotName) + { + this.maxAttemptsBeforeConfused = maxAttemptsBeforeConfused; + this.confusedKnotName = confusedKnotName; + } + +} \ No newline at end of file diff --git a/mutters-ink-bot/src/main/java/com/rabidgremlin/mutters/bot/ink/functions/GetLongTermAttributeFunction.java b/mutters-ink-bot/src/main/java/com/rabidgremlin/mutters/bot/ink/functions/GetLongTermAttributeFunction.java index a52f35a..660f25c 100644 --- a/mutters-ink-bot/src/main/java/com/rabidgremlin/mutters/bot/ink/functions/GetLongTermAttributeFunction.java +++ b/mutters-ink-bot/src/main/java/com/rabidgremlin/mutters/bot/ink/functions/GetLongTermAttributeFunction.java @@ -1,96 +1,96 @@ -package com.rabidgremlin.mutters.bot.ink.functions; - -import com.bladecoder.ink.runtime.Story; -import com.rabidgremlin.mutters.bot.ink.CurrentResponse; -import com.rabidgremlin.mutters.bot.ink.InkBotFunction; -import com.rabidgremlin.mutters.core.IntentMatch; -import com.rabidgremlin.mutters.core.session.Session; - -/** - * This function gets the value of a long term session attribute. These attributes are not removed at the end of a - * conversation so they can be used to share context between conversations in the same session. - * - * For example in your Ink script you could have: - * ``` - * VAR current_order = "" - * - * === check_order_status === - * ::GET_LONG_TERM_ATTR name::currentorder var::current_order - * { - * - current_order == "": - * -> get_order_number_for_status_check // this would prompt for order number and store in current_order then jump to check_order_status - * - else: - * -> check_order_status // retrieves and displays status for order number in current_order - * } - * -> END - * ``` - * - * Note: If there is no long term attribute with the specified name in the session then the specified Ink variable - * will be set to "". - * - * - * @author rabidgremlin - * - */ -public class GetLongTermAttributeFunction - implements InkBotFunction -{ - - /* - * (non-Javadoc) - * - * @see com.rabidgremlin.mutters.bot.ink.InkBotFunction#getFunctionName() - */ - @Override - public String getFunctionName() - { - return "GET_LONG_TERM_ATTR"; - } - - /* - * (non-Javadoc) - * - * @see com.rabidgremlin.mutters.bot.ink.InkBotFunction#respondexecute(CurrentResponse currentResponse, Session - * session, IntentMatch intentMatch, Story story, String param) - */ - @Override - public void execute(CurrentResponse currentResponse, Session session, IntentMatch intentMatch, Story story, String param) - { - FunctionDetails details = FunctionHelper.parseFunctionString(param); - - if (details.getFunctionParams() == null) - { - throw new IllegalArgumentException("Missing name and variable value for GET_LONG_TERM_ATTR"); - } - - String name = details.getFunctionParams().get("name"); - if (name == null) - { - throw new IllegalArgumentException("Missing name value for GET_LONG_TERM_ATTR"); - } - - String var = details.getFunctionParams().get("var"); - if (var == null) - { - throw new IllegalArgumentException("Missing var value for GET_LONG_TERM_ATTR"); - } - - try - { - Object value = session.getLongTermAttribute(name); - if (value == null) - { - story.getVariablesState().set(var, ""); - } - else - { - story.getVariablesState().set(var, value); - } - } - catch(Exception e) - { - throw new RuntimeException("Failed to get long term attribute",e); - } - } - -} +package com.rabidgremlin.mutters.bot.ink.functions; + +import com.bladecoder.ink.runtime.Story; +import com.rabidgremlin.mutters.bot.ink.CurrentResponse; +import com.rabidgremlin.mutters.bot.ink.InkBotFunction; +import com.rabidgremlin.mutters.core.IntentMatch; +import com.rabidgremlin.mutters.core.session.Session; + +/** + * This function gets the value of a long term session attribute. These attributes are not removed at the end of a + * conversation so they can be used to share context between conversations in the same session. + * + * For example in your Ink script you could have: + * ``` + * VAR current_order = "" + * + * === check_order_status === + * ::GET_LONG_TERM_ATTR name::currentorder var::current_order + * { + * - current_order == "": + * -> get_order_number_for_status_check // this would prompt for order number and store in current_order then jump to check_order_status + * - else: + * -> check_order_status // retrieves and displays status for order number in current_order + * } + * -> END + * ``` + * + * Note: If there is no long term attribute with the specified name in the session then the specified Ink variable + * will be set to "". + * + * + * @author rabidgremlin + * + */ +public class GetLongTermAttributeFunction + implements InkBotFunction +{ + + /* + * (non-Javadoc) + * + * @see com.rabidgremlin.mutters.bot.ink.InkBotFunction#getFunctionName() + */ + @Override + public String getFunctionName() + { + return "GET_LONG_TERM_ATTR"; + } + + /* + * (non-Javadoc) + * + * @see com.rabidgremlin.mutters.bot.ink.InkBotFunction#respondexecute(CurrentResponse currentResponse, Session + * session, IntentMatch intentMatch, Story story, String param) + */ + @Override + public void execute(CurrentResponse currentResponse, Session session, IntentMatch intentMatch, Story story, String param) + { + FunctionDetails details = FunctionHelper.parseFunctionString(param); + + if (details.getFunctionParams() == null) + { + throw new IllegalArgumentException("Missing name and variable value for GET_LONG_TERM_ATTR"); + } + + String name = details.getFunctionParams().get("name"); + if (name == null) + { + throw new IllegalArgumentException("Missing name value for GET_LONG_TERM_ATTR"); + } + + String var = details.getFunctionParams().get("var"); + if (var == null) + { + throw new IllegalArgumentException("Missing var value for GET_LONG_TERM_ATTR"); + } + + try + { + Object value = session.getLongTermAttribute(name); + if (value == null) + { + story.getVariablesState().set(var, ""); + } + else + { + story.getVariablesState().set(var, value); + } + } + catch(Exception e) + { + throw new RuntimeException("Failed to get long term attribute",e); + } + } + +} diff --git a/mutters-ink-bot/src/test/ink/order/main.ink b/mutters-ink-bot/src/test/ink/order/main.ink index 34b4aaf..a4c33ed 100644 --- a/mutters-ink-bot/src/test/ink/order/main.ink +++ b/mutters-ink-bot/src/test/ink/order/main.ink @@ -6,8 +6,8 @@ VAR order_number = "" === start === -+ CreateOrderIntent -> create_order -+ CheckStatusIntent -> check_status ++ [CreateOrderIntent] -> create_order ++ [CheckStatusIntent] -> check_status -> END === create_order === @@ -40,9 +40,9 @@ For order {order_number} ? ::SET_HINT Yes or No ::ADD_QUICK_REPLY Yes ::ADD_QUICK_REPLY No -+ YesIntent ++ [YesIntent] -> display_order_details -+ NoIntent ++ [NoIntent] // customer wants to talk about another order so unset current order long term attribute ::REMOVE_LONG_TERM_ATTR name::currentorder -> get_order_number_for_status_check diff --git a/mutters-ink-bot/src/test/ink/taxi/confused.ink b/mutters-ink-bot/src/test/ink/taxi/confused.ink index 9fa3fce..9721ac1 100644 --- a/mutters-ink-bot/src/test/ink/taxi/confused.ink +++ b/mutters-ink-bot/src/test/ink/taxi/confused.ink @@ -9,8 +9,8 @@ I'm struggling with that one. Do you want me to call our service line for you? ::SET_HINT Yes or no ::ADD_QUICK_REPLY Yes ::ADD_QUICK_REPLY No -+ YesIntent ++ [YesIntent] Calling our service operators now. Please hold the line. -+ NoIntent ++ [NoIntent] Okay. I'm here if you need me. --> END \ No newline at end of file +- -> END \ No newline at end of file diff --git a/mutters-ink-bot/src/test/ink/taxi/intents.ink b/mutters-ink-bot/src/test/ink/taxi/intents.ink index 00489a4..8378a58 100644 --- a/mutters-ink-bot/src/test/ink/taxi/intents.ink +++ b/mutters-ink-bot/src/test/ink/taxi/intents.ink @@ -1,6 +1,6 @@ == start == -+ OrderTaxi -> order_taxi -+ CancelTaxi -> cancel_taxi -+ WhereTaxi -> where_taxi ++ [OrderTaxi] -> order_taxi ++ [CancelTaxi] -> cancel_taxi ++ [WhereTaxi] -> where_taxi diff --git a/mutters-ink-bot/src/test/ink/taxi/main.ink b/mutters-ink-bot/src/test/ink/taxi/main.ink index 25181ad..914f8b7 100644 --- a/mutters-ink-bot/src/test/ink/taxi/main.ink +++ b/mutters-ink-bot/src/test/ink/taxi/main.ink @@ -6,6 +6,4 @@ INCLUDE globals.ink INCLUDE confused.ink - - -> start diff --git a/mutters-ink-bot/src/test/ink/taxi/order_taxi.ink b/mutters-ink-bot/src/test/ink/taxi/order_taxi.ink index b723133..1017c5b 100644 --- a/mutters-ink-bot/src/test/ink/taxi/order_taxi.ink +++ b/mutters-ink-bot/src/test/ink/taxi/order_taxi.ink @@ -2,24 +2,19 @@ VAR address="" VAR taxiNo = "" == order_taxi == - - - (order_taxi_loop) { - - address == "": - -> request_address - - else: - -> order_the_taxi + - address == "": -> request_address + - else: -> order_the_taxi } --> END = request_address What is the pick up address ? ::SET_REPROMPT Where would you like to be picked up ? ::SET_HINT 123 Someplace Rd -+ GaveAddress ++ [GaveAddress] - -> order_taxi_loop - + = order_the_taxi ::ORDER_TAXI Taxi {taxiNo} is on its way diff --git a/mutters-ink-bot/src/test/java/com/rabidgremlin/mutters/bot/ink/TestConfusedBot.java b/mutters-ink-bot/src/test/java/com/rabidgremlin/mutters/bot/ink/TestConfusedBot.java index 85d4f90..32ebb08 100644 --- a/mutters-ink-bot/src/test/java/com/rabidgremlin/mutters/bot/ink/TestConfusedBot.java +++ b/mutters-ink-bot/src/test/java/com/rabidgremlin/mutters/bot/ink/TestConfusedBot.java @@ -1,189 +1,189 @@ -package com.rabidgremlin.mutters.bot.ink; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.hamcrest.CoreMatchers.startsWith; -import static org.junit.Assert.assertThat; - -import org.junit.Before; -import org.junit.Test; - -import com.rabidgremlin.mutters.core.Context; -import com.rabidgremlin.mutters.core.bot.BotException; -import com.rabidgremlin.mutters.core.bot.BotResponse; -import com.rabidgremlin.mutters.core.session.Session; - -import java.util.List; - -public class TestConfusedBot -{ - private TaxiInkBot botWithConfusedKnot; - private TaxiInkBot botWithoutConfusedKnot; - private TaxiInkBot botWithConfusedKnotWithReprompts; - - - class TaxiBotWithConfusedKnotConfig extends TaxiInkBotConfiguration - { - @Override - public ConfusedKnot getConfusedKnot() - { - return new ConfusedKnot(2, "confused_bot"); - } - } - - class TaxiBotWithConfusedKnotWithRepromptsConfig extends TaxiInkBotConfiguration - { - @Override - public ConfusedKnot getConfusedKnot() - { - return new ConfusedKnot(2, "confused_bot_with_handover"); - } - } - - class TaxiBotWithoutConfusedKnotConfig extends TaxiInkBotConfiguration - { - @Override - public ConfusedKnot getConfusedKnot() - { - return null; - } - } - - - - @Before - public void setUp() - { - botWithConfusedKnot = new TaxiInkBot(new TaxiBotWithConfusedKnotConfig()); - botWithoutConfusedKnot = new TaxiInkBot(new TaxiBotWithoutConfusedKnotConfig()); - botWithConfusedKnotWithReprompts = new TaxiInkBot(new TaxiBotWithConfusedKnotWithRepromptsConfig()); - } - - - @Test - public void testNoConfusedKnot() - throws BotException - { - Session session = new Session(); - Context context = new Context(); - - BotResponse response = botWithoutConfusedKnot.respond(session, context, "Order me a taxi"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("What is the pick up address ?")); - assertThat(response.isAskResponse(), is(true)); - - response = botWithoutConfusedKnot.respond(session, context, "etretret ret"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("Where would you like to be picked up ?")); - assertThat(response.isAskResponse(), is(true)); - - response = botWithoutConfusedKnot.respond(session, context, "eeeetttt"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("Where would you like to be picked up ?")); - assertThat(response.isAskResponse(), is(true)); - - response = botWithoutConfusedKnot.respond(session, context, "Where is my cab ?"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("Where would you like to be picked up ?")); - assertThat(response.isAskResponse(), is(true)); - - response = botWithoutConfusedKnot.respond(session, context, "ewewew"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("Where would you like to be picked up ?")); - assertThat(response.isAskResponse(), is(true)); - } - - - @Test - public void testBasicConfusedKnot() - throws BotException - { - Session session = new Session(); - Context context = new Context(); - - BotResponse response = botWithConfusedKnot.respond(session, context, "Order me a taxi"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("What is the pick up address ?")); - assertThat(response.isAskResponse(), is(true)); - - response = botWithConfusedKnot.respond(session, context, "etretret ret"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("Where would you like to be picked up ?")); - assertThat(response.isAskResponse(), is(true)); - - response = botWithConfusedKnot.respond(session, context, "Where is my cab ?"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), startsWith("I'm sorry I'm not understanding you at all")); - assertThat(response.isAskResponse(), is(false)); - } - - - @Test - public void testStopConfusion() - throws BotException - { - Session session = new Session(); - Context context = new Context(); - - BotResponse response = botWithConfusedKnot.respond(session, context, "Order me a taxi"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("What is the pick up address ?")); - assertThat(response.isAskResponse(), is(true)); - - response = botWithConfusedKnot.respond(session, context, "etretret ret"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("Where would you like to be picked up ?")); - assertThat(response.isAskResponse(), is(true)); - - response = botWithConfusedKnot.respond(session, context, "136 River Road"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), startsWith("Taxi 1983 is on its way")); - assertThat(response.isAskResponse(), is(false)); - } - - @Test - public void testConfusedKnotWithReprompts() - throws BotException - { - Session session = new Session(); - Context context = new Context(); - - BotResponse response = botWithConfusedKnotWithReprompts.respond(session, context, "Order me a taxi"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("What is the pick up address ?")); - assertThat(response.isAskResponse(), is(true)); - - response = botWithConfusedKnotWithReprompts.respond(session, context, "skibidi whop"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), is("Where would you like to be picked up ?")); - assertThat(response.isAskResponse(), is(true)); - - response = botWithConfusedKnotWithReprompts.respond(session, context, "Where is my cab ?"); - - assertThat(response, is(notNullValue())); - assertThat(response.getResponse(), startsWith("I'm struggling with that one. Do you want me to call our service line for you?")); - assertThat(response.isAskResponse(), is(true)); - - assertThat(SessionUtils.getReprompt(session), is("Would you like me to call our service line?")); - - assertThat(response.getHint(), is("Yes or no")); - - assertThat(response.getQuickReplies().size(), is(2)); - List quickReplies = response.getQuickReplies(); - assertThat(quickReplies.get(0), is("Yes")); - assertThat(quickReplies.get(1), is("No")); - } -} +package com.rabidgremlin.mutters.bot.ink; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.startsWith; +import static org.junit.Assert.assertThat; + +import org.junit.Before; +import org.junit.Test; + +import com.rabidgremlin.mutters.core.Context; +import com.rabidgremlin.mutters.core.bot.BotException; +import com.rabidgremlin.mutters.core.bot.BotResponse; +import com.rabidgremlin.mutters.core.session.Session; + +import java.util.List; + +public class TestConfusedBot +{ + private TaxiInkBot botWithConfusedKnot; + private TaxiInkBot botWithoutConfusedKnot; + private TaxiInkBot botWithConfusedKnotWithReprompts; + + + class TaxiBotWithConfusedKnotConfig extends TaxiInkBotConfiguration + { + @Override + public ConfusedKnot getConfusedKnot() + { + return new ConfusedKnot(2, "confused_bot"); + } + } + + class TaxiBotWithConfusedKnotWithRepromptsConfig extends TaxiInkBotConfiguration + { + @Override + public ConfusedKnot getConfusedKnot() + { + return new ConfusedKnot(2, "confused_bot_with_handover"); + } + } + + class TaxiBotWithoutConfusedKnotConfig extends TaxiInkBotConfiguration + { + @Override + public ConfusedKnot getConfusedKnot() + { + return null; + } + } + + + + @Before + public void setUp() + { + botWithConfusedKnot = new TaxiInkBot(new TaxiBotWithConfusedKnotConfig()); + botWithoutConfusedKnot = new TaxiInkBot(new TaxiBotWithoutConfusedKnotConfig()); + botWithConfusedKnotWithReprompts = new TaxiInkBot(new TaxiBotWithConfusedKnotWithRepromptsConfig()); + } + + + @Test + public void testNoConfusedKnot() + throws BotException + { + Session session = new Session(); + Context context = new Context(); + + BotResponse response = botWithoutConfusedKnot.respond(session, context, "Order me a taxi"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("What is the pick up address ?")); + assertThat(response.isAskResponse(), is(true)); + + response = botWithoutConfusedKnot.respond(session, context, "etretret ret"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("Where would you like to be picked up ?")); + assertThat(response.isAskResponse(), is(true)); + + response = botWithoutConfusedKnot.respond(session, context, "eeeetttt"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("Where would you like to be picked up ?")); + assertThat(response.isAskResponse(), is(true)); + + response = botWithoutConfusedKnot.respond(session, context, "Where is my cab ?"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("Where would you like to be picked up ?")); + assertThat(response.isAskResponse(), is(true)); + + response = botWithoutConfusedKnot.respond(session, context, "ewewew"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("Where would you like to be picked up ?")); + assertThat(response.isAskResponse(), is(true)); + } + + + @Test + public void testBasicConfusedKnot() + throws BotException + { + Session session = new Session(); + Context context = new Context(); + + BotResponse response = botWithConfusedKnot.respond(session, context, "Order me a taxi"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("What is the pick up address ?")); + assertThat(response.isAskResponse(), is(true)); + + response = botWithConfusedKnot.respond(session, context, "etretret ret"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("Where would you like to be picked up ?")); + assertThat(response.isAskResponse(), is(true)); + + response = botWithConfusedKnot.respond(session, context, "Where is my cab ?"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), startsWith("I'm sorry I'm not understanding you at all")); + assertThat(response.isAskResponse(), is(false)); + } + + + @Test + public void testStopConfusion() + throws BotException + { + Session session = new Session(); + Context context = new Context(); + + BotResponse response = botWithConfusedKnot.respond(session, context, "Order me a taxi"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("What is the pick up address ?")); + assertThat(response.isAskResponse(), is(true)); + + response = botWithConfusedKnot.respond(session, context, "etretret ret"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("Where would you like to be picked up ?")); + assertThat(response.isAskResponse(), is(true)); + + response = botWithConfusedKnot.respond(session, context, "136 River Road"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), startsWith("Taxi 1983 is on its way")); + assertThat(response.isAskResponse(), is(false)); + } + + @Test + public void testConfusedKnotWithReprompts() + throws BotException + { + Session session = new Session(); + Context context = new Context(); + + BotResponse response = botWithConfusedKnotWithReprompts.respond(session, context, "Order me a taxi"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("What is the pick up address ?")); + assertThat(response.isAskResponse(), is(true)); + + response = botWithConfusedKnotWithReprompts.respond(session, context, "skibidi whop"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), is("Where would you like to be picked up ?")); + assertThat(response.isAskResponse(), is(true)); + + response = botWithConfusedKnotWithReprompts.respond(session, context, "Where is my cab ?"); + + assertThat(response, is(notNullValue())); + assertThat(response.getResponse(), startsWith("I'm struggling with that one. Do you want me to call our service line for you?")); + assertThat(response.isAskResponse(), is(true)); + + assertThat(SessionUtils.getReprompt(session), is("Would you like me to call our service line?")); + + assertThat(response.getHint(), is("Yes or no")); + + assertThat(response.getQuickReplies().size(), is(2)); + List quickReplies = response.getQuickReplies(); + assertThat(quickReplies.get(0), is("Yes")); + assertThat(quickReplies.get(1), is("No")); + } +} \ No newline at end of file diff --git a/mutters-ink-bot/src/test/resources/orderbot.ink.json b/mutters-ink-bot/src/test/resources/orderbot.ink.json index 29b92e1..e3a32e4 100644 --- a/mutters-ink-bot/src/test/resources/orderbot.ink.json +++ b/mutters-ink-bot/src/test/resources/orderbot.ink.json @@ -1 +1 @@ -{"inkVersion":17,"root":[{"->":"start"},"done",{"start":[["ev",{"^->":"start.0.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.c","flg":2},{"s":["^CreateOrderIntent",{"->":"$r","var":true},null],"c":["ev",{"^->":"start.0.c.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.s"},[{"#n":"$r2"}],"\n",{"->":"create_order"},"\n",{"#f":7}]}],["ev",{"^->":"start.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.c","flg":2},{"s":["^CheckStatusIntent",{"->":"$r","var":true},null],"c":["ev",{"^->":"start.1.c.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.s"},[{"#n":"$r2"}],"\n",{"->":"check_status"},"\n","end",{"#f":7}]}],{"#f":3}],"create_order":["ev",123456,"/ev",{"temp=":"order_number","re":true},"^Your order ",["G>","ev",{"VAR?":"order_number"},"out","/ev","G<",null],"^ has been created!","\n","^::SET_LONG_TERM_ATTR name::currentorder value::",["G>","ev",{"VAR?":"order_number"},"out","/ev","G<",null],"\n","end",{"#f":3}],"check_status":["^::GET_LONG_TERM_ATTR name::currentorder var::order_number","\n",["G>",["ev",{"VAR?":"order_number"},"str","^","/str","==","/ev",{"->":".^.b","c":true},{"b":[{"->":".^.^.^.^.get_order_number_for_status_check"},{"->":".^.^.^.3"},null]}],[{"->":".^.b"},{"b":[{"->":".^.^.^.^.check_order_status"},{"->":".^.^.^.3"},null]}],"nop","G<",null],"\n","end",{"get_order_number_for_status_check":["^What is the order number of the order you want to check the status of ?","\n","end",{"#f":3}],"check_order_status":["^For order ",["G>","ev",{"VAR?":"order_number"},"out","/ev","G<",null],"^ ?","\n","^::SET_REPROMPT For order ",["G>","ev",{"VAR?":"order_number"},"out","/ev","G<",null],"^ ? Please answer Yes or No.","\n","^::SET_HINT Yes or No","\n","^::ADD_QUICK_REPLY Yes","\n","^::ADD_QUICK_REPLY No","\n",["ev",{"^->":"check_status.check_order_status.14.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.c","flg":2},{"s":["^YesIntent",{"->":"$r","var":true},null],"c":["ev",{"^->":"check_status.check_order_status.14.c.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.s"},[{"#n":"$r2"}],"\n","\n",{"->":".^.^.^.^.display_order_details"},{"#f":7}]}],["ev",{"^->":"check_status.check_order_status.15.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.c","flg":2},{"s":["^NoIntent",{"->":"$r","var":true},null],"c":["ev",{"^->":"check_status.check_order_status.15.c.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.s"},[{"#n":"$r2"}],"\n","\n","^::REMOVE_LONG_TERM_ATTR name::currentorder","\n",{"->":".^.^.^.^.get_order_number_for_status_check"},"end",{"#f":7}]}],{"#f":3}],"display_order_details":["^Order ",["G>","ev",{"VAR?":"order_number"},"out","/ev","G<",null],"^ is currently being packed.","\n","^::SET_LONG_TERM_ATTR name::currentorder value::",["G>","ev",{"VAR?":"order_number"},"out","/ev","G<",null],"\n","end",{"#f":3}],"#f":3}],"global decl":["ev","str","^","/str",{"VAR=":"order_number"},"/ev","end",null],"#f":3}],"listDefs":{}} \ No newline at end of file +{"inkVersion":19,"root":[[{"->":"start"},["done",{"#f":7,"#n":"g-0"}],null],"done",{"start":[["ev","str","^CreateOrderIntent","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^CheckStatusIntent","/str","/ev",{"*":".^.c-1","flg":4},{"c-0":["^ ",{"->":"create_order"},"\n",{"#f":7}],"c-1":["^ ",{"->":"check_status"},"\n","end",{"#f":7}]}],{"#f":3}],"create_order":["ev",123456,"/ev",{"temp=":"order_number","re":true},"^Your order ","ev",{"VAR?":"order_number"},"out","/ev","^ has been created!","\n","^::SET_LONG_TERM_ATTR name::currentorder value::","ev",{"VAR?":"order_number"},"out","/ev","\n","end",{"#f":3}],"check_status":["^::GET_LONG_TERM_ATTR name::currentorder var::order_number","\n",["ev",{"VAR?":"order_number"},"str","^","/str","==","/ev",{"->":".^.b","c":true},{"b":["\n",{"->":".^.^.^.get_order_number_for_status_check"},{"->":".^.^.^.4"},null]}],[{"->":".^.b"},{"b":["\n",{"->":".^.^.^.check_order_status"},{"->":".^.^.^.4"},null]}],"nop","\n","end",{"get_order_number_for_status_check":["^What is the order number of the order you want to check the status of ?","\n","end",{"#f":3}],"check_order_status":[["^For order ","ev",{"VAR?":"order_number"},"out","/ev","^ ?","\n","^::SET_REPROMPT For order ","ev",{"VAR?":"order_number"},"out","/ev","^ ? Please answer Yes or No.","\n","^::SET_HINT Yes or No","\n","^::ADD_QUICK_REPLY Yes","\n","^::ADD_QUICK_REPLY No","\n","ev","str","^YesIntent","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^NoIntent","/str","/ev",{"*":".^.c-1","flg":4},{"c-0":["\n",{"->":".^.^.^.^.display_order_details"},{"#f":7}],"c-1":["\n","^::REMOVE_LONG_TERM_ATTR name::currentorder","\n",{"->":".^.^.^.^.get_order_number_for_status_check"},"end",{"#f":7}]}],{"#f":3}],"display_order_details":["^Order ","ev",{"VAR?":"order_number"},"out","/ev","^ is currently being packed.","\n","^::SET_LONG_TERM_ATTR name::currentorder value::","ev",{"VAR?":"order_number"},"out","/ev","\n","end",{"#f":3}],"#f":3}],"global decl":["ev","str","^","/str",{"VAR=":"order_number"},"/ev","end",null],"#f":3}],"listDefs":{}} \ No newline at end of file diff --git a/mutters-ink-bot/src/test/resources/taxibot.ink.json b/mutters-ink-bot/src/test/resources/taxibot.ink.json index dec5463..4cbd759 100644 --- a/mutters-ink-bot/src/test/resources/taxibot.ink.json +++ b/mutters-ink-bot/src/test/resources/taxibot.ink.json @@ -1 +1 @@ -{"inkVersion":17,"root":["\n","\n","\n","\n","\n","\n",{"->":"start"},"done",{"start":[["ev",{"^->":"start.0.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.c","flg":2},{"s":["^OrderTaxi",{"->":"$r","var":true},null],"c":["ev",{"^->":"start.0.c.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.s"},[{"#n":"$r2"}],"\n",{"->":"order_taxi"},"\n",null]}],["ev",{"^->":"start.1.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.c","flg":2},{"s":["^CancelTaxi",{"->":"$r","var":true},null],"c":["ev",{"^->":"start.1.c.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.s"},[{"#n":"$r2"}],"\n",{"->":"cancel_taxi"},"\n",null]}],["ev",{"^->":"start.2.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.c","flg":2},{"s":["^WhereTaxi",{"->":"$r","var":true},null],"c":["ev",{"^->":"start.2.c.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.s"},[{"#n":"$r2"}],"\n",{"->":"where_taxi"},"\n",null]}],{"#f":3}],"order_taxi":[[[["G>",["ev",{"VAR?":"address"},"str","^","/str","==","/ev",{"->":".^.b","c":true},{"b":[{"->":"order_taxi.request_address"},{"->":".^.^.^.3"},null]}],[{"->":".^.b"},{"b":[{"->":"order_taxi.order_the_taxi"},{"->":".^.^.^.3"},null]}],"nop","G<",null],"\n","end",{"#n":"order_taxi_loop"}],null],{"request_address":[["^What is the pick up address ?","\n","^::SET_REPROMPT Where would you like to be picked up ?","\n","^::SET_HINT 123 Someplace Rd","\n",["ev",{"^->":"order_taxi.request_address.0.6.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.c","flg":2},{"s":["^GaveAddress",{"->":"$r","var":true},null],"c":["ev",{"^->":"order_taxi.request_address.0.6.c.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.s"},[{"#n":"$r2"}],"\n","\n",{"->":".^.^.^.g-0"},null]}],{"g-0":[{"->":".^.^.^.^.0.order_taxi_loop"},null]}],{"#f":3}],"order_the_taxi":["^::ORDER_TAXI","\n","^Taxi ",["G>","ev",{"VAR?":"taxiNo"},"out","/ev","G<",null],"^ is on its way","\n","^::ADD_ATTACHMENT type::link url::http://trackcab.example.com/t/",["G>","ev",{"VAR?":"taxiNo"},"out","/ev","G<",null],"^ title::Track your taxi here","\n","^::ADD_QUICK_REPLY Where is my taxi?","\n","^::ADD_QUICK_REPLY Cancel my taxi","\n","end",{"#f":3}],"#f":3}],"cancel_taxi":["^Your taxi has been cancelled","\n","end",{"#f":3}],"where_taxi":["^Your taxi is about 7 minutes away","\n","end",{"#f":3}],"stop":["^Ok","\n","end",{"#f":3}],"help":["^I can help you order a taxi or find out the location of your current taxi.","\n","^Try say \"Order a cab\" or \"Where is my cab\"","\n","end",{"#f":3}],"confused_bot":["^I'm sorry I'm not understanding you at all :(","\n","^If you are in a hurry, please call 555-12345 to order your taxi.","\n","end",{"#f":3}],"confused_bot_with_handover":["^I'm struggling with that one. Do you want me to call our service line for you?","\n","^::SET_REPROMPT Would you like me to call our service line?","\n","^::SET_HINT Yes or no","\n","^::ADD_QUICK_REPLY Yes","\n","^::ADD_QUICK_REPLY No","\n",["ev",{"^->":"confused_bot_with_handover.10.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.c","flg":2},{"s":["^YesIntent",{"->":"$r","var":true},null],"c":["ev",{"^->":"confused_bot_with_handover.10.c.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.s"},[{"#n":"$r2"}],"\n","\n","^Calling our service operators now. Please hold the line.","\n",null]}],["ev",{"^->":"confused_bot_with_handover.11.$r1"},{"temp=":"$r"},"str",{"->":".^.s"},[{"#n":"$r1"}],"/str","/ev",{"*":".^.c","flg":2},{"s":["^NoIntent",{"->":"$r","var":true},null],"c":["ev",{"^->":"confused_bot_with_handover.11.c.$r2"},"/ev",{"temp=":"$r"},{"->":".^.^.s"},[{"#n":"$r2"}],"\n","\n","^Okay. I'm here if you need me.","\n","end",null]}],{"#f":3}],"global decl":["ev","str","^","/str",{"VAR=":"address"},"str","^","/str",{"VAR=":"taxiNo"},"/ev","end",null],"#f":3}],"listDefs":{}} \ No newline at end of file +{"inkVersion":19,"root":[["\n","\n","\n","\n","\n","\n",{"->":"start"},["done",{"#f":7,"#n":"g-0"}],null],"done",{"start":[["ev","str","^OrderTaxi","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^CancelTaxi","/str","/ev",{"*":".^.c-1","flg":4},"ev","str","^WhereTaxi","/str","/ev",{"*":".^.c-2","flg":4},{"c-0":["^ ",{"->":"order_taxi"},"\n",{"#f":7}],"c-1":["^ ",{"->":"cancel_taxi"},"\n",{"#f":7}],"c-2":["^ ",{"->":"where_taxi"},"\n",{"#f":7}]}],{"#f":3}],"order_taxi":[[[["ev",{"VAR?":"address"},"str","^","/str","==","/ev",{"->":".^.b","c":true},{"b":["\n",{"->":"order_taxi.request_address"},{"->":".^.^.^.2"},null]}],[{"->":".^.b"},{"b":["\n",{"->":"order_taxi.order_the_taxi"},{"->":".^.^.^.2"},null]}],"nop","\n",{"#f":7,"#n":"order_taxi_loop"}],null],{"request_address":[["^What is the pick up address ?","\n","^::SET_REPROMPT Where would you like to be picked up ?","\n","^::SET_HINT 123 Someplace Rd","\n","ev","str","^GaveAddress","/str","/ev",{"*":".^.c-0","flg":4},{"c-0":["\n",{"->":".^.^.g-0"},{"#f":7}],"g-0":[{"->":".^.^.^.^.0.order_taxi_loop"},{"#f":7}]}],{"#f":3}],"order_the_taxi":["^::ORDER_TAXI","\n","^Taxi ","ev",{"VAR?":"taxiNo"},"out","/ev","^ is on its way","\n","^::ADD_ATTACHMENT type::link url::http://trackcab.example.com/t/","ev",{"VAR?":"taxiNo"},"out","/ev","^ title::Track your taxi here","\n","^::ADD_QUICK_REPLY Where is my taxi?","\n","^::ADD_QUICK_REPLY Cancel my taxi","\n","end",{"#f":3}],"#f":3}],"cancel_taxi":["^Your taxi has been cancelled","\n","end",{"#f":3}],"where_taxi":["^Your taxi is about 7 minutes away","\n","end",{"#f":3}],"stop":["^Ok","\n","end",{"#f":3}],"help":["^I can help you order a taxi or find out the location of your current taxi.","\n","^Try say \"Order a cab\" or \"Where is my cab\"","\n","end",{"#f":3}],"confused_bot":["^I'm sorry I'm not understanding you at all :(","\n","^If you are in a hurry, please call 555-12345 to order your taxi.","\n","end",{"#f":3}],"confused_bot_with_handover":[["^I'm struggling with that one. Do you want me to call our service line for you?","\n","^::SET_REPROMPT Would you like me to call our service line?","\n","^::SET_HINT Yes or no","\n","^::ADD_QUICK_REPLY Yes","\n","^::ADD_QUICK_REPLY No","\n","ev","str","^YesIntent","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^NoIntent","/str","/ev",{"*":".^.c-1","flg":4},{"c-0":["\n","^Calling our service operators now. Please hold the line.","\n",{"->":".^.^.g-0"},{"#f":7}],"c-1":["\n","^Okay. I'm here if you need me.","\n",{"->":".^.^.g-0"},{"#f":7}],"g-0":["end",{"#f":7}]}],{"#f":3}],"global decl":["ev","str","^","/str",{"VAR=":"address"},"str","^","/str",{"VAR=":"taxiNo"},"/ev","end",null],"#f":3}],"listDefs":{}} \ No newline at end of file diff --git a/mutters-templated-intent/src/main/java/com/rabidgremlin/mutters/templated/TemplatedIntentMatcher.java b/mutters-templated-intent/src/main/java/com/rabidgremlin/mutters/templated/TemplatedIntentMatcher.java index f378777..b332a9b 100644 --- a/mutters-templated-intent/src/main/java/com/rabidgremlin/mutters/templated/TemplatedIntentMatcher.java +++ b/mutters-templated-intent/src/main/java/com/rabidgremlin/mutters/templated/TemplatedIntentMatcher.java @@ -1,99 +1,99 @@ -package com.rabidgremlin.mutters.templated; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Set; - -import org.apache.commons.lang3.StringUtils; - -import com.rabidgremlin.mutters.core.Context; -import com.rabidgremlin.mutters.core.IntentMatch; -import com.rabidgremlin.mutters.core.IntentMatcher; -import com.rabidgremlin.mutters.core.Tokenizer; - -/** - * This is an IntentMatcher that matches against utterance templates. - * - * @author rabidgremlin - * - */ -public class TemplatedIntentMatcher - implements IntentMatcher -{ - private final List intents = new ArrayList<>(); - - private final Tokenizer tokenizer; - - /** - * Constructor. - * - * @param tokenizer The tokenizer to use for parsing users inpout and utterance templates. - */ - public TemplatedIntentMatcher(Tokenizer tokenizer) - { - this.tokenizer = tokenizer; - - // Check that tokenizer preserves slot identifiers - String[] tokens = tokenizer.tokenize("{City} {Date}"); - if (tokens == null || tokens.length != 2 || - !tokens[0].equalsIgnoreCase("{city}") || !tokens[1].equalsIgnoreCase("{date}")) - { - throw new IllegalArgumentException("Invalid tokenizer. It removes slot identifiers in {}s"); - } - } - - /** - * Adds a new intent to the matcher. - * - * @param name The name of the intent. - * @return The new TemplatedIntent. - */ - public TemplatedIntent addIntent(String name) - { - TemplatedIntent intent = new TemplatedIntent(name, tokenizer); - intents.add(intent); - return intent; - } - - /* - * (non-Javadoc) - * - * @see com.rabidgremlin.mutters.core.IntentMatcher#match(java.lang.String, com.rabidgremlin.mutters.core.Context, - * Set expectedIntents) - */ - @Override - public IntentMatch match(String utterance, Context context, Set expectedIntents, HashMap debugValues) - { - // utterance is blank, nothing to match on - if (StringUtils.isBlank(utterance)) - { - return null; - } - - String[] cleanedUtterance = tokenizer.tokenize(utterance); - - // do we have some tokens after cleaning ? - if (cleanedUtterance.length == 0) - { - return null; - } - - for (TemplatedIntent intent : intents) - { - // If given null list of expected intents, expected to try match regardless - if (expectedIntents == null || expectedIntents.contains(intent.getName())) - { - TemplatedUtteranceMatch utteranceMatch = intent.matches(cleanedUtterance, context); - if (utteranceMatch.isMatched()) - { - return new IntentMatch(intent, utteranceMatch.getSlotMatches(), utterance); - } - } - } - - return null; - } - -} +package com.rabidgremlin.mutters.templated; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; + +import com.rabidgremlin.mutters.core.Context; +import com.rabidgremlin.mutters.core.IntentMatch; +import com.rabidgremlin.mutters.core.IntentMatcher; +import com.rabidgremlin.mutters.core.Tokenizer; + +/** + * This is an IntentMatcher that matches against utterance templates. + * + * @author rabidgremlin + * + */ +public class TemplatedIntentMatcher + implements IntentMatcher +{ + private final List intents = new ArrayList<>(); + + private final Tokenizer tokenizer; + + /** + * Constructor. + * + * @param tokenizer The tokenizer to use for parsing users inpout and utterance templates. + */ + public TemplatedIntentMatcher(Tokenizer tokenizer) + { + this.tokenizer = tokenizer; + + // Check that tokenizer preserves slot identifiers + String[] tokens = tokenizer.tokenize("{City} {Date}"); + if (tokens == null || tokens.length != 2 || + !tokens[0].equalsIgnoreCase("{city}") || !tokens[1].equalsIgnoreCase("{date}")) + { + throw new IllegalArgumentException("Invalid tokenizer. It removes slot identifiers in {}s"); + } + } + + /** + * Adds a new intent to the matcher. + * + * @param name The name of the intent. + * @return The new TemplatedIntent. + */ + public TemplatedIntent addIntent(String name) + { + TemplatedIntent intent = new TemplatedIntent(name, tokenizer); + intents.add(intent); + return intent; + } + + /* + * (non-Javadoc) + * + * @see com.rabidgremlin.mutters.core.IntentMatcher#match(java.lang.String, com.rabidgremlin.mutters.core.Context, + * Set expectedIntents) + */ + @Override + public IntentMatch match(String utterance, Context context, Set expectedIntents, HashMap debugValues) + { + // utterance is blank, nothing to match on + if (StringUtils.isBlank(utterance)) + { + return null; + } + + String[] cleanedUtterance = tokenizer.tokenize(utterance); + + // do we have some tokens after cleaning ? + if (cleanedUtterance.length == 0) + { + return null; + } + + for (TemplatedIntent intent : intents) + { + // If given null list of expected intents, expected to try match regardless + if (expectedIntents == null || expectedIntents.contains(intent.getName())) + { + TemplatedUtteranceMatch utteranceMatch = intent.matches(cleanedUtterance, context); + if (utteranceMatch.isMatched()) + { + return new IntentMatch(intent, utteranceMatch.getSlotMatches(), utterance); + } + } + } + + return null; + } + +}