diff --git a/src/main/java/io/spokestack/spokestack/Spokestack.java b/src/main/java/io/spokestack/spokestack/Spokestack.java index 96a9245..01c68bb 100644 --- a/src/main/java/io/spokestack/spokestack/Spokestack.java +++ b/src/main/java/io/spokestack/spokestack/Spokestack.java @@ -2,6 +2,8 @@ import android.content.Context; import io.spokestack.spokestack.dialogue.DialogueManager; +import io.spokestack.spokestack.dialogue.FinalizedPrompt; +import io.spokestack.spokestack.dialogue.Prompt; import io.spokestack.spokestack.nlu.NLUManager; import io.spokestack.spokestack.nlu.NLUResult; import io.spokestack.spokestack.nlu.tensorflow.parsers.DigitsParser; @@ -146,6 +148,12 @@ private Spokestack(Builder builder) throws Exception { .addTTSListener(this) .build(); } + + if (builder.dialogueBuilder.hasPolicy() + || builder.speechConfig.containsKey("dialogue-policy-file") + || builder.speechConfig.containsKey("dialogue-policy-class")) { + this.dialogueManager = builder.dialogueBuilder.build(); + } } /** @@ -349,6 +357,55 @@ public void stopPlayback() { } } + // dialogue + + /** + * @return The dialogue manager currently in use. + */ + public DialogueManager getDialogueManager() { + return dialogueManager; + } + + /** + * Finalize a prompt, interpolating template strings using the current + * conversation data store. + * + *

+ * This method can only be used if a dialogue manager is active. + *

+ * + * @param prompt The prompt to be finalized. + * @return The finalized prompt. + * + * @see DialogueManager#finalizePrompt(Prompt) + * @see io.spokestack.spokestack.dialogue.ConversationData + */ + public FinalizedPrompt finalizePrompt(Prompt prompt) { + if (this.dialogueManager != null) { + return dialogueManager.finalizePrompt(prompt); + } + return null; + } + + /** + * Stores an object in the conversation data store for use in interpolating + * system prompts. + * + *

+ * This method can only be used if a dialogue manager is active. + *

+ * + * @param key The name of the object to store. + * @param value The object to store. + * + * @see io.spokestack.spokestack.dialogue.ConversationData#set(String, Object) + */ + public void putConversationData(String key, Object value) { + if (this.dialogueManager != null) { + dialogueManager.getDataStore().set(key, value); + } + } + // listeners /** @@ -948,7 +1005,9 @@ public Spokestack build() throws Exception { + "TTSManager.Builder.setAndroidContext()"); } } - if (!this.speechConfig.containsKey("dialogue-policy-file") + + if (!this.dialogueBuilder.hasPolicy() + && !this.speechConfig.containsKey("dialogue-policy-file") && !this.speechConfig.containsKey("dialogue-policy-class")) { this.useDialogue = false; } diff --git a/src/main/java/io/spokestack/spokestack/dialogue/DialogueManager.java b/src/main/java/io/spokestack/spokestack/dialogue/DialogueManager.java index a7fac4b..31da97d 100644 --- a/src/main/java/io/spokestack/spokestack/dialogue/DialogueManager.java +++ b/src/main/java/io/spokestack/spokestack/dialogue/DialogueManager.java @@ -133,6 +133,17 @@ public ConversationData getDataStore() { return this.dataStore; } + /** + * Finalize a prompt, interpolating template strings using the current + * conversation data store. + * + * @param prompt The prompt to be finalized. + * @return The finalized prompt. + */ + public FinalizedPrompt finalizePrompt(Prompt prompt) { + return prompt.finalizePrompt(this.dataStore); + } + /** * Dump the dialogue policy's current state to the currently registered data * store. This can be used in conjunction with {@link #load(String) load()} @@ -271,6 +282,15 @@ public Builder withDialoguePolicy(String policyClass) { return this; } + /** + * @return whether this builder has a dialogue policy enabled via + * class or JSON file. + */ + public boolean hasPolicy() { + return this.config.containsKey("dialogue-policy-file") + || this.config.containsKey("dialogye-policy-class"); + } + /** * Specify the data store to use for conversation data. * diff --git a/src/main/java/io/spokestack/spokestack/dialogue/FinalizedPrompt.java b/src/main/java/io/spokestack/spokestack/dialogue/FinalizedPrompt.java new file mode 100644 index 0000000..db3562d --- /dev/null +++ b/src/main/java/io/spokestack/spokestack/dialogue/FinalizedPrompt.java @@ -0,0 +1,169 @@ +package io.spokestack.spokestack.dialogue; + +import androidx.annotation.NonNull; + +import java.util.Arrays; +import java.util.List; + +/** + * A finalized prompt contains the same fields as a {@link Prompt}, but instead + * of template placeholders, its contents are fully interpolated strings ready + * to be displayed to the user or synthesized by TTS. + */ +public final class FinalizedPrompt { + private final String id; + private final String text; + private final String voice; + private final Proposal proposal; + private final FinalizedPrompt[] reprompts; + private final boolean endsConversation; + + private FinalizedPrompt(Builder builder) { + this.id = builder.id; + this.text = builder.text; + if (builder.voice == null) { + this.voice = builder.text; + } else { + this.voice = builder.voice; + } + this.proposal = builder.proposal; + this.reprompts = builder.reprompts; + this.endsConversation = builder.endsConversation; + } + + /** + * @return The prompt's ID. + */ + public String getId() { + return id; + } + + /** + * Get a version of the prompt formatted for TTS synthesis. + * + * @return A version of the prompt formatted for TTS synthesis. + */ + public String getVoice() { + return voice; + } + + /** + * Get a version of the prompt formatted for print. + * + * @return A version of the prompt formatted for print. + */ + public String getText() { + return text; + } + + /** + * @return this prompt's proposal. + */ + public Proposal getProposal() { + return proposal; + } + + /** + * @return any reprompts associated with this prompt. + */ + public FinalizedPrompt[] getReprompts() { + return reprompts; + } + + /** + * @return {@code true} if the conversation should end after the current + * prompt is delivered; {@code false} otherwise. + */ + public boolean endsConversation() { + return endsConversation; + } + + @Override + public String toString() { + return "Prompt{" + + "id='" + id + '\'' + + ", text='" + text + '\'' + + ", voice='" + voice + '\'' + + ", proposal=" + proposal + + ", reprompts=" + Arrays.toString(reprompts) + + ", endsConversation=" + endsConversation + + '}'; + } + + /** + * Prompt builder API. + */ + public static final class Builder { + + private final String id; + private final String text; + private String voice; + private Proposal proposal; + private FinalizedPrompt[] reprompts; + private boolean endsConversation; + + /** + * Create a new prompt builder with the minimal set of required data. + * + * @param promptId The prompt's ID. + * @param textReply A reply template formatted for print. + */ + public Builder(@NonNull String promptId, @NonNull String textReply) { + this.id = promptId; + this.text = textReply; + this.reprompts = new FinalizedPrompt[0]; + } + + /** + * Signals that the prompt to be built should end the conversation with + * the user. + * + * @return the updated builder + */ + public Builder endsConversation() { + this.endsConversation = true; + return this; + } + + /** + * Add a reply template formatted for TTS synthesis to the current + * prompt. + * + * @param voiceReply The voice prompt to be added. + * @return the updated builder + */ + public Builder withVoice(@NonNull String voiceReply) { + this.voice = voiceReply; + return this; + } + + /** + * Add a proposal to the current prompt. + * + * @param prop The proposal to be added. + * @return the updated builder + */ + public Builder withProposal(@NonNull Proposal prop) { + this.proposal = prop; + return this; + } + + /** + * Specify reprompts for the current prompt. + * + * @param prompts The reprompts to attach. + * @return the updated builder + */ + public Builder withReprompts(@NonNull List prompts) { + this.reprompts = prompts.toArray(new FinalizedPrompt[0]); + return this; + } + + /** + * @return a complete prompt created from the current builder state. + */ + public FinalizedPrompt build() { + return new FinalizedPrompt(this); + } + } +} diff --git a/src/main/java/io/spokestack/spokestack/dialogue/InMemoryConversationData.java b/src/main/java/io/spokestack/spokestack/dialogue/InMemoryConversationData.java index 5e4d398..7b53360 100644 --- a/src/main/java/io/spokestack/spokestack/dialogue/InMemoryConversationData.java +++ b/src/main/java/io/spokestack/spokestack/dialogue/InMemoryConversationData.java @@ -6,6 +6,11 @@ /** * A simple data store for conversation data that resides in memory and lasts * only as long as the dialogue manager holding it. + * + *

+ * Formats values for both display and synthesis using {@link + * String#valueOf(Object)}. + *

*/ public class InMemoryConversationData implements ConversationData { diff --git a/src/main/java/io/spokestack/spokestack/dialogue/Prompt.java b/src/main/java/io/spokestack/spokestack/dialogue/Prompt.java index 44eed2a..c415ed4 100644 --- a/src/main/java/io/spokestack/spokestack/dialogue/Prompt.java +++ b/src/main/java/io/spokestack/spokestack/dialogue/Prompt.java @@ -2,6 +2,7 @@ import androidx.annotation.NonNull; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.regex.Matcher; @@ -127,6 +128,32 @@ public boolean endsConversation() { return endsConversation; } + /** + * Finalize this prompt, filling in all placeholders with data from the + * conversation's data store. + * + * @param dataStore The current state of the conversation data to use for + * filling placeholders in prompts. + * @return A finalized version of this prompt ready for display/synthesis. + */ + public FinalizedPrompt finalizePrompt(ConversationData dataStore) { + List finalReprompts = new ArrayList<>(); + for (Prompt prompt : this.reprompts) { + finalReprompts.add(prompt.finalizePrompt(dataStore)); + } + + FinalizedPrompt.Builder builder = new FinalizedPrompt.Builder( + this.id, this.getText(dataStore)) + .withVoice(this.getVoice(dataStore)) + .withProposal(this.proposal) + .withReprompts(finalReprompts); + + if (this.endsConversation) { + builder.endsConversation(); + } + return builder.build(); + } + @Override public String toString() { return "Prompt{" diff --git a/src/test/java/io/spokestack/spokestack/SpokestackTest.java b/src/test/java/io/spokestack/spokestack/SpokestackTest.java index dd381ba..96c4bb1 100644 --- a/src/test/java/io/spokestack/spokestack/SpokestackTest.java +++ b/src/test/java/io/spokestack/spokestack/SpokestackTest.java @@ -2,6 +2,11 @@ import android.content.Context; import android.os.SystemClock; +import io.spokestack.spokestack.dialogue.DialogueEvent; +import io.spokestack.spokestack.dialogue.DialogueManager; +import io.spokestack.spokestack.dialogue.FinalizedPrompt; +import io.spokestack.spokestack.dialogue.Prompt; +import io.spokestack.spokestack.dialogue.Proposal; import io.spokestack.spokestack.nlu.NLUManager; import io.spokestack.spokestack.nlu.NLUResult; import io.spokestack.spokestack.nlu.tensorflow.NLUTestUtils; @@ -21,6 +26,8 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.powermock.api.mockito.PowerMockito.mockStatic; @@ -256,6 +263,48 @@ public void testTts() throws Exception { assertDoesNotThrow(spokestack::close); } + @Test + public void testDialogue() throws Exception { + // just test convenience methods for now + TestAdapter listener = new TestAdapter(); + + SpeechPipeline.Builder pipelineBuilder = new SpeechPipeline.Builder(); + + Spokestack.Builder builder = new Spokestack + .Builder(pipelineBuilder, mockTts()) + .withoutWakeword() + .addListener(listener); + + // explicitly include dialogue management + builder.getDialogueBuilder() + .withPolicyFile("src/test/resources/dialogue.json"); + + builder = mockAndroidComponents(builder); + Spokestack spokestack = new Spokestack(builder, mockNlu()); + + listener.setSpokestack(spokestack); + DialogueManager dialogueManager = spokestack.getDialogueManager(); + + spokestack.putConversationData("key", "value"); + Object storedValue = dialogueManager.getDataStore().get("key"); + assertEquals("value", String.valueOf(storedValue)); + + Prompt prompt = new Prompt.Builder("id", "{{key}}") + .withVoice("{{voice}}") + .withProposal(new Proposal()) + .endsConversation() + .build(); + + spokestack.putConversationData("voice", "one two three"); + + FinalizedPrompt finalized = spokestack.finalizePrompt(prompt); + + assertNotNull(finalized); + assertEquals("value", finalized.getText()); + assertEquals("one two three", finalized.getVoice()); + assertTrue(finalized.endsConversation()); + } + @Test public void testListenerManagement() throws Exception { mockStatic(SystemClock.class); @@ -420,6 +469,8 @@ static class TestAdapter extends SpokestackAdapter { SpeechContext speechContext; Spokestack spokestack; LinkedBlockingQueue nluResults = new LinkedBlockingQueue<>(); + LinkedBlockingQueue dialogueEvents = + new LinkedBlockingQueue<>(); LinkedBlockingQueue ttsEvents = new LinkedBlockingQueue<>(); LinkedBlockingQueue speechEvents = new LinkedBlockingQueue<>(); @@ -434,6 +485,7 @@ public void setSpokestack(Spokestack spokestack) { public void clear() { this.speechEvents.clear(); this.nluResults.clear(); + this.dialogueEvents.clear(); this.ttsEvents.clear(); this.traces.clear(); this.errors.clear(); @@ -461,6 +513,11 @@ public void nluResult(@NotNull NLUResult result) { } } + @Override + public void onDialogueEvent(@NotNull DialogueEvent event) { + this.dialogueEvents.add(event); + } + @Override public void trace(@NotNull SpokestackModule module, @NotNull String message) { diff --git a/src/test/java/io/spokestack/spokestack/dialogue/DialogueManagerTest.java b/src/test/java/io/spokestack/spokestack/dialogue/DialogueManagerTest.java index 69f8699..cebfe23 100644 --- a/src/test/java/io/spokestack/spokestack/dialogue/DialogueManagerTest.java +++ b/src/test/java/io/spokestack/spokestack/dialogue/DialogueManagerTest.java @@ -90,6 +90,22 @@ public void dataOperations() throws Exception { manager.load("newState"); assertEquals("newState", manager.dump()); + + // finalize a prompt + Prompt prompt = new Prompt.Builder("id", "{{text}}") + .withVoice("{{voice}}") + .withProposal(new Proposal()) + .endsConversation() + .build(); + + conversationData.set("text", "123"); + conversationData.set("voice", "one two three"); + + FinalizedPrompt finalized = prompt.finalizePrompt(conversationData); + + assertEquals("123", finalized.getText()); + assertEquals("one two three", finalized.getVoice()); + assertTrue(finalized.endsConversation()); } @Test diff --git a/src/test/java/io/spokestack/spokestack/dialogue/PromptTest.java b/src/test/java/io/spokestack/spokestack/dialogue/PromptTest.java index 9cf2bc5..8cb4317 100644 --- a/src/test/java/io/spokestack/spokestack/dialogue/PromptTest.java +++ b/src/test/java/io/spokestack/spokestack/dialogue/PromptTest.java @@ -53,4 +53,23 @@ public void testTemplateFilling() { assertEquals("one two three", reprompt.getVoice(data)); assertTrue(reprompt.endsConversation()); } + + @Test + public void testFinalization() { + ConversationData data = new InMemoryConversationData(); + Prompt prompt = new Prompt.Builder("id", "{{text}}") + .withVoice("{{voice}}") + .withProposal(new Proposal()) + .endsConversation() + .build(); + + data.set("text", "123"); + data.set("voice", "one two three"); + + FinalizedPrompt finalized = prompt.finalizePrompt(data); + + assertEquals("123", finalized.getText()); + assertEquals("one two three", finalized.getVoice()); + assertTrue(finalized.endsConversation()); + } } \ No newline at end of file