All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.spokestack.spokestack.dialogue.policy.RuleBasedDialoguePolicy Maven / Gradle / Ivy

package io.spokestack.spokestack.dialogue.policy;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.stream.MalformedJsonException;
import io.spokestack.spokestack.SpeechConfig;
import io.spokestack.spokestack.dialogue.ConversationData;
import io.spokestack.spokestack.dialogue.ConversationState;
import io.spokestack.spokestack.dialogue.DialogueDispatcher;
import io.spokestack.spokestack.dialogue.DialogueEvent;
import io.spokestack.spokestack.dialogue.DialogueManager;
import io.spokestack.spokestack.dialogue.DialoguePolicy;
import io.spokestack.spokestack.dialogue.Prompt;
import io.spokestack.spokestack.dialogue.Proposal;
import io.spokestack.spokestack.nlu.NLUResult;
import io.spokestack.spokestack.nlu.Slot;
import io.spokestack.spokestack.util.EventTracer;

import java.io.FileReader;
import java.io.IOException;
import java.io.Reader;
import java.util.HashMap;
import java.util.Map;

import static io.spokestack.spokestack.dialogue.policy.DialogueAct.*;
import static io.spokestack.spokestack.dialogue.policy.Model.*;

/**
 * Spokestack's built-in rule-based dialogue policy.
 *
 * 

* This policy loads a dialogue configuration from a JSON file and determines * transitions for the app based on the rules and scenes referenced in that * file. *

* *

* See the * online documentation for more information on the Spokestack dialogue * format. *

* *

* This dialogue policy employs two-step processing of user requests to allow * apps to retrieve or operate on external data before completing a user turn. * It will fire {@link DialogueEvent.Type#ACTION ACTION} events to registered * dialogue event listeners when an app feature is triggered. When all * operations on external data are complete and data relevant to the action has * been made accessible via the current {@link ConversationData data store}, the * app should call {@link DialogueManager#completeTurn(boolean) completeTurn()} * on the dialogue manager in use so that any follow-up navigation/prompts can * occur. *

* *

* This component supports the following configuration properties: *

*
    *
  • * dialogue-policy-file (string, required): file system path to the * dialogue policy configuration. *
  • *
*/ public class RuleBasedDialoguePolicy implements DialoguePolicy { static final String STATE_KEY = DialogueManager.SLOT_PREFIX + "policy_state"; private final Gson gson; private final Model conversation; private ConversationHistory history; private PendingTurn pendingTurn; /** * Creates a dialogue policy from the supplied configuration. * * @param config Configuration containing the path to a policy file. * @throws IOException if there is an error reading the policy * file. * @throws MalformedJsonException if the policy file contains invalid JSON. */ public RuleBasedDialoguePolicy(SpeechConfig config) throws IOException, MalformedJsonException { String policyFile = config.getString("dialogue-policy-file"); this.gson = new GsonBuilder() .disableHtmlEscaping() .create(); try (Reader reader = new FileReader(policyFile)) { this.conversation = gson.getAdapter(Model.class).fromJson(reader); } resetHistory(); } private void resetHistory() { this.history = new ConversationHistory(this.conversation); } /** * @return the current conversation history. Used for testing. */ ConversationHistory getHistory() { return this.history; } @Override public String dump(ConversationData conversationData) { HashMap slots = new HashMap<>(); for (String key : this.history.getSlotKeys()) { slots.put(key, conversationData.get(key)); } HashMap state = new HashMap<>(); state.put("history", this.history); state.put("slots", slots); String serialized = gson.toJson(state); conversationData.set(STATE_KEY, serialized); return serialized; } @Override public void load(String state, ConversationData conversationData) { if (state == null) { return; } PolicyState deserialized = gson.fromJson(state, PolicyState.class); this.history = deserialized.history; for (String key : deserialized.slots.keySet()) { conversationData.set(key, deserialized.slots.get(key)); } } @Override public void completeTurn(boolean success, ConversationData conversationData, DialogueDispatcher eventDispatcher) { if (this.pendingTurn == null || !this.pendingTurn.isAction()) { return; } AbstractNode destNode = null; if (success) { String destination = ((Feature) this.pendingTurn.getNode()).getDestination(); if (destination != null && !destination.equals(this.history.getCurrentNode())) { destNode = this.conversation.fetchNode(destination); } } else { destNode = findErrorNode(this.pendingTurn.turn, this.history.getCurrentNode(), eventDispatcher); } this.pendingTurn = null; // if we've changed places in the conversation, fire events notifying // the app of both the new state and a prompt related to that state if (destNode != null) { SystemTurn systemTurn = new SystemTurn(); systemTurn.setNode(destNode); finalizeTurn(systemTurn); dispatchTurnEvents(null, systemTurn, eventDispatcher); this.history.updatePath(destNode.getId()); } } @Override public void handleTurn(NLUResult nluResult, ConversationData conversationData, DialogueDispatcher eventDispatcher) { UserTurn userTurn = parseResult(nluResult); if (userTurn.getDialogueAct() == DialogueAct.UNKNOWN) { dispatchError(eventDispatcher, "unsupported intent: " + nluResult.getIntent()); return; } storeSlots(userTurn.getSlots(), conversationData); // the first adjustment picks up things like rewrites due to intent // proposals; then we have to readjust after dealing with pending // state to pick up things like incomplete actions that should lead to // changed nodes // TODO we should probably just make failing to call completeTurn() // an error condition and not try to correct for it on the next call // to handleTurn() userTurn = adjustTurn(userTurn); SystemTurn systemTurn = clearPendingState(userTurn, conversationData, eventDispatcher); userTurn = adjustTurn(userTurn); if (systemTurn == null) { systemTurn = findTarget(userTurn, eventDispatcher); systemTurn = evalRules(userTurn, systemTurn, conversationData); } finalizeTurn(systemTurn); dispatchTurnEvents(userTurn, systemTurn, eventDispatcher); updateState(userTurn, systemTurn, conversationData); } private UserTurn parseResult(NLUResult nluResult) { Map slots = nluResult.getSlots(); return new UserTurn(nluResult.getUtterance(), nluResult.getIntent(), slots); } private void storeSlots(Map slots, ConversationData conversationData) { for (Map.Entry entry : slots.entrySet()) { conversationData.set(entry.getKey(), entry.getValue()); } } private SystemTurn clearPendingState(UserTurn userTurn, ConversationData conversationData, DialogueDispatcher eventDispatcher) { if (this.pendingTurn == null) { return null; } if (this.pendingTurn.isAction() && !this.pendingTurn.failedRule) { eventDispatcher.trace(EventTracer.Level.WARN, "incomplete action detected (%s); assuming success", this.pendingTurn.getNode().getName()); String destination = ((Feature) this.pendingTurn.getNode()).getDestination(); if (destination != null) { this.history.updatePath(destination); } } // only certain intents should cause reevaluation of the pending turn's // rules SystemTurn response = null; if (userTurn.getDialogueAct() == INFORM || userTurn.getDialogueAct() == CONFIRM) { response = evalRules( userTurn, this.pendingTurn.systemTurn, conversationData); } this.pendingTurn = null; return response; } private UserTurn adjustTurn(UserTurn userTurn) { DialogueAct originalAct = userTurn.getDialogueAct(); String intent = getIntentOverride(userTurn.getIntent()); intent = processProposalFollowups(intent, originalAct); return new UserTurn( userTurn.getUtterance(), intent, userTurn.getSlots()); } private String getIntentOverride(String original) { String nodeId = this.history.getCurrentNode(); if (nodeId != null) { AbstractNode currentNode = this.conversation.fetchNode(nodeId); for (Rule rule : currentNode.getRules()) { if (rule.getType() == Rule.Type.INTENT_OVERRIDE && rule.getKey().equals(original)) { return rule.getValue(); } } } return original; } private String processProposalFollowups(String original, DialogueAct act) { if (act != ACCEPT && act != REJECT) { return original; } String adjusted = null; Prompt lastPrompt = this.history.getLastPrompt(); if (lastPrompt != null) { Proposal proposal = this.history.getLastPrompt().getProposal(); if (proposal != null) { adjusted = (act == ACCEPT) ? proposal.getAccept() : proposal.getReject(); } } return (adjusted != null) ? adjusted : getDefaultProposalIntent(act); } private String getDefaultProposalIntent(DialogueAct act) { return (act == ACCEPT) ? "navigate.next" : "navigate.back"; } private SystemTurn findTarget(UserTurn userTurn, DialogueDispatcher eventDispatcher) { SystemTurn turn = new SystemTurn(); String newNode = null; String nodeId = this.history.getCurrentNode(); switch (userTurn.getDialogueAct()) { case EXIT: newNode = this.conversation.baseNode("exit"); break; case GREET: newNode = this.conversation.baseNode("greet"); break; case NAVIGATE: newNode = getNavigationTarget(userTurn, eventDispatcher); break; case COMMAND: String featureName = userTurn.getDetail(); Feature feature = this.conversation.lookupFeature(featureName, nodeId); if (feature == null) { dispatchError(eventDispatcher, "missing feature: " + featureName); } else { turn.setNode(feature); } break; case HELP: case INFORM: Node specialNode = this.conversation.findSpecialNode(userTurn, nodeId); if (specialNode == null) { dispatchError(eventDispatcher, "missing frame: " + userTurn.getDialogueAct().name().toLowerCase()); } else { newNode = specialNode.getName(); } break; case REPEAT: turn.setPrompt(this.history.getLastPrompt()); break; case READ_SCREEN: // TODO figure out prompt here -- // a designated node scheme within help? case ACCEPT: case REJECT: case CONFIRM: case ASK: case UNKNOWN: // - accept and reject are impossible after adjustment // - confirm is handled as part of a pending turn // and can't be produced by the NLU // - others stay on the current node default: dispatchError(eventDispatcher, "unexpected intent: " + userTurn.getIntent()); break; } // find the node's ID if (turn.getNode() == null) { AbstractNode node = null; if (newNode != null) { node = this.conversation.lookupNode(newNode); if (node == null) { dispatchError(eventDispatcher, "missing node: " + newNode); String current = this.history.getCurrentNode(); node = findErrorNode(userTurn, current, eventDispatcher); } } turn.setNode(node); } return turn; } /** * Get the ID of the node targeted by a navigation intent. *

* Special cases are handled first and result in navigation to an error node * if any necessary preconditions are not met. * * @param userTurn The full user turn. * @param eventDispatcher Event dispatcher for reporting errors. * @return The ID of the node resulting from the navigation. */ private String getNavigationTarget(UserTurn userTurn, DialogueDispatcher eventDispatcher) { // check navigation special cases String currentNodeId = this.history.getCurrentNode(); String nodeName = userTurn.getDetail(); AbstractNode node = this.conversation.lookupNode(nodeName); switch (nodeName) { case "back": String prevId = this.history.getPreviousNode(); if (prevId == null) { node = findErrorNode( userTurn, currentNodeId, eventDispatcher); } else { node = this.conversation.fetchNode(prevId); } break; case "next": if (currentNodeId == null) { node = findErrorNode(userTurn, null, eventDispatcher); } else { Node currentNode = (Node) this.conversation.fetchNode(currentNodeId); String nextId = currentNode.getNext(); if (nextId == null) { node = findErrorNode( userTurn, currentNodeId, eventDispatcher); } else { node = this.conversation.fetchNode(nextId); } } break; case "reset": Node firstNode = this.conversation.lookupNode("greet"); if (firstNode == null) { node = findErrorNode( userTurn, currentNodeId, eventDispatcher); } else { node = firstNode; } resetHistory(); break; default: break; } return (node != null) ? node.getName() : null; } private AbstractNode findErrorNode(UserTurn userTurn, String currentNode, DialogueDispatcher eventDispatcher) { Node errorNode = this.conversation.findErrorNode( userTurn, currentNode); if (errorNode == null) { dispatchError(eventDispatcher, "missing frame: error"); return null; } return errorNode; } private void dispatchError(DialogueDispatcher eventDispatcher, String message) { ConversationState state = new ConversationState.Builder().withError(message).build(); DialogueEvent event = new DialogueEvent(DialogueEvent.Type.ERROR, state); eventDispatcher.dispatch(event); } private SystemTurn evalRules(UserTurn userTurn, SystemTurn systemTurn, ConversationData conversationData) { AbstractNode node = systemTurn.getNode(); if (node == null) { String currentNode = this.history.getCurrentNode(); // if we don't have a turn node or current node, // there won't be any rules to check if (currentNode == null) { return systemTurn; } else { node = this.conversation.fetchNode(currentNode); } } SystemTurn systemResponse = checkRules( userTurn, systemTurn, node, conversationData, false); AbstractNode newNode = systemResponse.getNode(); if (newNode != null && newNode != node) { systemResponse = checkRules( userTurn, systemResponse, newNode, conversationData, true); } return systemResponse; } private SystemTurn checkRules( UserTurn userTurn, SystemTurn systemTurn, AbstractNode node, ConversationData conversationData, boolean redirected) { Rule[] rules = node.getRules(); SystemTurn systemResponse = systemTurn; boolean ruleTriggered = false; for (Rule rule : rules) { if (rule.shouldTrigger(userTurn, conversationData, redirected)) { systemResponse = rule.getResponse(this.conversation); ruleTriggered = true; break; } } if (ruleTriggered) { this.pendingTurn = new PendingTurn(userTurn, systemTurn, true); } return systemResponse; } private void finalizeTurn(SystemTurn systemTurn) { AbstractNode node = systemTurn.getNode(); if (node != null && !node.getId().equals(this.history.getCurrentNode())) { if (systemTurn.getPrompt() == null) { Prompt prompt = node.randomPrompt(); systemTurn.setPrompt(prompt); } } else { systemTurn.setNode(null); } } private void dispatchTurnEvents(UserTurn userTurn, SystemTurn systemTurn, DialogueDispatcher eventDispatcher) { ConversationState state = createEventState(userTurn, systemTurn); DialogueEvent event; if (state.getPrompt() != null) { event = new DialogueEvent(DialogueEvent.Type.PROMPT, state); eventDispatcher.dispatch(event); } if (state.getAction() != null) { if (this.pendingTurn == null) { this.pendingTurn = new PendingTurn(userTurn, systemTurn, false); } event = new DialogueEvent(DialogueEvent.Type.ACTION, state); eventDispatcher.dispatch(event); } if (state.getNodeName() != null) { event = new DialogueEvent(DialogueEvent.Type.STATE_CHANGE, state); eventDispatcher.dispatch(event); } } private ConversationState createEventState(UserTurn userTurn, SystemTurn systemTurn) { ConversationState.Builder builder = new ConversationState.Builder(); AbstractNode node = systemTurn.getNode(); if (node instanceof Feature) { builder.withAction(node.getName(), userTurn.getSlots()); } else if (node != null) { builder.withNode(node.getName()); } return builder .withPrompt(systemTurn.getPrompt()) .build(); } private void updateState(UserTurn userTurn, SystemTurn systemTurn, ConversationData conversationData) { conversationData.set( DialogueManager.SLOT_PREFIX + "last_intent", userTurn.getIntent()); this.history.update(userTurn, systemTurn); } /** * Internal class for tracking turns that require one or more followup * interactions to complete. The happy-path system response must be * maintained across interactions; it will either be delivered when all * rules are satisfied or discarded if a future user turn changes the * subject. */ static class PendingTurn { final UserTurn turn; final SystemTurn systemTurn; final boolean failedRule; PendingTurn(UserTurn userTurn, SystemTurn response, boolean didFail) { this.turn = userTurn; this.systemTurn = response; this.failedRule = didFail; } public AbstractNode getNode() { return systemTurn.getNode(); } public boolean isAction() { return systemTurn.getNode() != null && systemTurn.getNode() instanceof Feature; } } /** * A container class used to serialize and deserialize internal policy * state. */ private static class PolicyState { final ConversationHistory history; final Map slots; /** * Create a new complete state. * * @param conversationHistory The current conversation history. * @param currentSlots A map of slot names to string values that * have been provided so far in the * conversation. */ PolicyState(ConversationHistory conversationHistory, Map currentSlots) { this.history = conversationHistory; this.slots = currentSlots; } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy