From 56f312f05f168c2df6da61c615b5210c50d341b5 Mon Sep 17 00:00:00 2001 From: lsocha Date: Wed, 24 Apr 2024 16:51:45 +0200 Subject: [PATCH 1/5] feat: Support AI Closes: SDK-3736 --- doc/ai.md | 65 ++++++++ src/intTest/java/com/box/sdk/BoxAIIT.java | 139 +++++++++++++++++ src/intTest/java/com/box/sdk/Retry.java | 3 + src/main/java/com/box/sdk/BoxAI.java | 141 ++++++++++++++++++ .../java/com/box/sdk/BoxAIDialogueEntry.java | 107 +++++++++++++ src/main/java/com/box/sdk/BoxAIItem.java | 121 +++++++++++++++ src/main/java/com/box/sdk/BoxAIResponse.java | 92 ++++++++++++ src/test/Fixtures/BoxAI/SendAIRequest200.json | 5 + src/test/Fixtures/BoxAI/SendAITextGen200.json | 5 + src/test/java/com/box/sdk/BoxAITest.java | 120 +++++++++++++++ .../java/com/box/sdk/BoxDateFormatTest.java | 8 + 11 files changed, 806 insertions(+) create mode 100644 doc/ai.md create mode 100644 src/intTest/java/com/box/sdk/BoxAIIT.java create mode 100644 src/main/java/com/box/sdk/BoxAI.java create mode 100644 src/main/java/com/box/sdk/BoxAIDialogueEntry.java create mode 100644 src/main/java/com/box/sdk/BoxAIItem.java create mode 100644 src/main/java/com/box/sdk/BoxAIResponse.java create mode 100644 src/test/Fixtures/BoxAI/SendAIRequest200.json create mode 100644 src/test/Fixtures/BoxAI/SendAITextGen200.json create mode 100644 src/test/java/com/box/sdk/BoxAITest.java diff --git a/doc/ai.md b/doc/ai.md new file mode 100644 index 000000000..c60754af4 --- /dev/null +++ b/doc/ai.md @@ -0,0 +1,65 @@ +AI +== + +AI allows to send an intelligence request to supported large language models and returns +an answer based on the provided prompt and items. + + + + +- [Send AI request](#send-ai-request) +- [Send AI text generation request](#send-ai-text-generation-request) + + + +Send AI request +-------------------------- + +To send an AI request to get an answer call static +[`sendAIRequest(String prompt, List items, Mode mode)`][send-ai-request] method. +In the request you have to provide a prompt, a list of items that your prompt refers to and a mode of the request. +There are two modes available: `SINGLE_ITEM_QA` and `MULTI_ITEM_QA`, which specifies if this request refers to +for a single or multiple items. + + +```java +BoxAIResponse response = BoxAI.sendAIRequest( + api, + "What is the content of the file?", + Collections.singletonList("123456", BoxAIItem.Type.FILE)), + BoxAI.Mode.SINGLE_ITEM_QA +); +``` + +NOTE: The AI endpoint may return a 412 status code if you use for your request a file which has just been updated to the box. +It usually takes a few seconds for the file to be indexed and available for the AI endpoint. + +[send-ai-request]: http://opensource.box.com/box-java-sdk/javadoc/com/box/sdk/BoxAI.html#sendAIRequest-com.box.sdk.BoxAPIConnection-java.lang.String- + +Send AI text generation request +-------------- + +To send an AI request to get an answer specifically focused on the creation of new text call static +[`sendAITextGenRequest(String prompt, List items, List dialogueHistory)`][send-ai-text-gen-request] method. +In the request you have to provide a prompt, a list of items that your prompt refers to and a dialogue history, +which provides additional context to the LLM in generating the response. + + +```java +List dialogueHistory = new ArrayList<>(); +dialogueHistory.add( + new BoxAIDialogueEntry( + "Make my email about public APIs sound more professional", + "Here is the first draft of your professional email about public APIs.", + BoxDateFormat.parse("2013-05-16T15:26:57-07:00") + ) + ); +BoxAIResponse response = BoxAI.sendAITextGenRequest( + api, + "Write an email to a client about the importance of public APIs.", + Collections.singletonList(new BoxAIItem("123456", BoxAIItem.Type.FILE)), + dialogueHistory +); +``` + +[send-ai-text-gen-request]: http://opensource.box.com/box-java-sdk/javadoc/com/box/sdk/BoxAI.html#sendAITextGenRequest-com.box.sdk.BoxAPIConnection-java.lang.String- \ No newline at end of file diff --git a/src/intTest/java/com/box/sdk/BoxAIIT.java b/src/intTest/java/com/box/sdk/BoxAIIT.java new file mode 100644 index 000000000..e3f7953fe --- /dev/null +++ b/src/intTest/java/com/box/sdk/BoxAIIT.java @@ -0,0 +1,139 @@ +package com.box.sdk; + +import static com.box.sdk.BoxApiProvider.jwtApiForServiceAccount; +import static com.box.sdk.CleanupTools.deleteFile; +import static com.box.sdk.Retry.retry; +import static com.box.sdk.UniqueTestFolder.removeUniqueFolder; +import static com.box.sdk.UniqueTestFolder.setupUniqeFolder; +import static com.box.sdk.UniqueTestFolder.uploadFileToUniqueFolder; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + + +/** + * {@link BoxGroup} related integration tests. + */ +public class BoxAIIT { + + @BeforeClass + public static void setup() { + setupUniqeFolder(); + } + + @AfterClass + public static void afterClass() { + removeUniqueFolder(); + } + + + @Test + public void askAISingleItem() throws InterruptedException { + BoxAPIConnection api = jwtApiForServiceAccount(); + String fileName = "[askAISingleItem] Test File.txt"; + BoxFile uploadedFile = uploadFileToUniqueFolder(api, fileName, "Test file"); + + try { + BoxFile.Info uploadedFileInfo = uploadedFile.getInfo(); + // When a file has been just uploaded, AI service may not be ready to return text response + // and 412 is returned + retry(() -> { + BoxAIResponse response = BoxAI.sendAIRequest( + api, + "What is the name of the file?", + Collections.singletonList(new BoxAIItem(uploadedFileInfo.getID(), BoxAIItem.Type.FILE)), + BoxAI.Mode.SINGLE_ITEM_QA + ); + assertThat(response.getAnswer(), containsString("Test file")); + assert response.getCreatedAt().after(new Date(System.currentTimeMillis())); + assertThat(response.getCompletionReason(), equalTo("done")); + }, 2, 2000); + + } finally { + deleteFile(uploadedFile); + } + } + + @Test + public void askAIMultipleItems() throws InterruptedException { + BoxAPIConnection api = jwtApiForServiceAccount(); + String fileName1 = "[askAIMultipleItems] Test File.txt"; + BoxFile uploadedFile1 = uploadFileToUniqueFolder(api, fileName1, "Test file"); + try { + String fileName2 = "[askAIMultipleItems] Weather forecast.txt"; + BoxFile uploadedFile2 = uploadFileToUniqueFolder(api, fileName2, "Test file"); + + BoxFile.Info uploadedFileInfo1 = uploadedFile1.getInfo(); + BoxFile.Info uploadedFileInfo2 = uploadedFile2.getInfo(); + + List items = new ArrayList<>(); + items.add(new BoxAIItem(uploadedFileInfo1.getID(), BoxAIItem.Type.FILE)); + items.add(new BoxAIItem(uploadedFileInfo2.getID(), BoxAIItem.Type.FILE)); + + // When a file has been just uploaded, AI service may not be ready to return text response + // and 412 is returned + retry(() -> { + BoxAIResponse response = BoxAI.sendAIRequest( + api, + "What is the content of these files?", + items, + BoxAI.Mode.MULTIPLE_ITEM_QA + ); + assertThat(response.getAnswer(), containsString("Test file")); + assert response.getCreatedAt().after(new Date(System.currentTimeMillis())); + assertThat(response.getCompletionReason(), equalTo("done")); + }, 2, 2000); + + } finally { + deleteFile(uploadedFile1); + } + } + + @Test + public void askAITextGenItemWithDialogueHistory() throws ParseException, InterruptedException { + BoxAPIConnection api = jwtApiForServiceAccount(); + String fileName = "[askAITextGenItemWithDialogueHistory] Test File.txt"; + Date date1 = BoxDateFormat.parse("2013-05-16T15:27:57-07:00"); + Date date2 = BoxDateFormat.parse("2013-05-16T15:26:57-07:00"); + + BoxFile uploadedFile = uploadFileToUniqueFolder(api, fileName, "Test file"); + try { + // When a file has been just uploaded, AI service may not be ready to return text response + // and 412 is returned + retry(() -> { + BoxFile.Info uploadedFileInfo = uploadedFile.getInfo(); + assertThat(uploadedFileInfo.getName(), is(equalTo(fileName))); + + List dialogueHistory = new ArrayList<>(); + dialogueHistory.add( + new BoxAIDialogueEntry("What is the name of the file?", "Test file", date1) + ); + dialogueHistory.add( + new BoxAIDialogueEntry("What is the size of the file?", "10kb", date2) + ); + BoxAIResponse response = BoxAI.sendAITextGenRequest( + api, + "What is the name of the file?", + Collections.singletonList(new BoxAIItem(uploadedFileInfo.getID(), BoxAIItem.Type.FILE)), + dialogueHistory + ); + assertThat(response.getAnswer(), containsString("name")); + assert response.getCreatedAt().after(new Date(System.currentTimeMillis())); + assertThat(response.getCompletionReason(), equalTo("done")); + }, 2, 2000); + + } finally { + deleteFile(uploadedFile); + } + } +} diff --git a/src/intTest/java/com/box/sdk/Retry.java b/src/intTest/java/com/box/sdk/Retry.java index ce41a8e14..13ca95a88 100644 --- a/src/intTest/java/com/box/sdk/Retry.java +++ b/src/intTest/java/com/box/sdk/Retry.java @@ -27,6 +27,9 @@ public static void retry(Runnable toExecute, int retries, int sleep) throws Inte break; } catch (Exception e) { retriesExecuted++; + if (retriesExecuted >= retries) { + throw e; + } LOGGER.debug( format("Retrying [%d/%d] becasue of Exception '%s'", retriesExecuted, retries, e.getMessage()) ); diff --git a/src/main/java/com/box/sdk/BoxAI.java b/src/main/java/com/box/sdk/BoxAI.java new file mode 100644 index 000000000..843c5a09e --- /dev/null +++ b/src/main/java/com/box/sdk/BoxAI.java @@ -0,0 +1,141 @@ +package com.box.sdk; + +import com.box.sdk.http.HttpMethod; +import com.eclipsesource.json.Json; +import com.eclipsesource.json.JsonArray; +import com.eclipsesource.json.JsonObject; +import java.net.URL; +import java.util.List; + + +public final class BoxAI { + + /** + * Ask AI url. + */ + public static final URLTemplate SEND_AI_REQUEST_URL = new URLTemplate("ai/ask"); + /** + * Text gen AI url. + */ + public static final URLTemplate SEND_AI_TEXT_GEN_REQUEST_URL = new URLTemplate("ai/text_gen"); + + private BoxAI() { + } + + /** + * Sends an AI request to supported LLMs and returns an answer specifically focused + * on the user's question given the provided items. + * @param api the API connection to be used by the created user. + * @param prompt The prompt provided by the client to be answered by the LLM. + * @param items The items to be processed by the LLM, currently only files are supported. + * @param mode The mode specifies if this request is for a single or multiple items. + * @return The response from the AI. + */ + public static BoxAIResponse sendAIRequest(BoxAPIConnection api, String prompt, List items, Mode mode) { + URL url = SEND_AI_REQUEST_URL.build(api.getBaseURL()); + JsonObject requestJSON = new JsonObject(); + requestJSON.add("mode", mode.toString()); + requestJSON.add("prompt", prompt); + + JsonArray itemsJSON = new JsonArray(); + for (BoxAIItem item : items) { + itemsJSON.add(item.getJSONObject()); + } + requestJSON.add("items", itemsJSON); + + BoxJSONRequest req = new BoxJSONRequest(api, url, HttpMethod.POST); + req.setBody(requestJSON.toString()); + + try (BoxJSONResponse response = req.send()) { + JsonObject responseJSON = Json.parse(response.getJSON()).asObject(); + return new BoxAIResponse(responseJSON); + } + } + + /** + * Sends an AI request to supported LLMs and returns an answer specifically focused on the creation of new text. + * @param api the API connection to be used by the created user. + * @param prompt The prompt provided by the client to be answered by the LLM. + * @param items The items to be processed by the LLM, currently only files are supported. + * @return The response from the AI. + */ + public static BoxAIResponse sendAITextGenRequest(BoxAPIConnection api, String prompt, List items) { + return sendAITextGenRequest(api, prompt, items, null); + } + + /** + * Sends an AI request to supported LLMs and returns an answer specifically focused on the creation of new text. + * @param api the API connection to be used by the created user. + * @param prompt The prompt provided by the client to be answered by the LLM. + * @param items The items to be processed by the LLM, currently only files are supported. + * @param dialogueHistory The history of prompts and answers previously passed to the LLM. + * This provides additional context to the LLM in generating the response. + * @return The response from the AI. + */ + public static BoxAIResponse sendAITextGenRequest( + BoxAPIConnection api, String prompt, List items, List dialogueHistory + ) { + URL url = SEND_AI_TEXT_GEN_REQUEST_URL.build(api.getBaseURL()); + JsonObject requestJSON = new JsonObject(); + requestJSON.add("prompt", prompt); + + JsonArray itemsJSON = new JsonArray(); + for (BoxAIItem item : items) { + itemsJSON.add(item.getJSONObject()); + } + requestJSON.add("items", itemsJSON); + + if (dialogueHistory != null) { + JsonArray dialogueHistoryJSON = new JsonArray(); + for (BoxAIDialogueEntry dialogueEntry : dialogueHistory) { + dialogueHistoryJSON.add(dialogueEntry.getJSONObject()); + } + requestJSON.add("dialogue_history", dialogueHistoryJSON); + } + + BoxJSONRequest req = new BoxJSONRequest(api, url, HttpMethod.POST); + req.setBody(requestJSON.toString()); + + try (BoxJSONResponse response = req.send()) { + JsonObject responseJSON = Json.parse(response.getJSON()).asObject(); + return new BoxAIResponse(responseJSON); + } + } + + public enum Mode { + /** + * Multiple items + */ + MULTIPLE_ITEM_QA("multiple_item_qa"), + + /** + * Single item + */ + SINGLE_ITEM_QA("single_item_qa"); + + private final String mode; + + Mode(String mode) { + this.mode = mode; + } + + static BoxAI.Mode fromJSONValue(String jsonValue) { + if (jsonValue.equals("multiple_item_qa")) { + return BoxAI.Mode.MULTIPLE_ITEM_QA; + } else if (jsonValue.equals("single_item_qa")) { + return BoxAI.Mode.SINGLE_ITEM_QA; + } else { + System.out.print("Invalid AI mode."); + return null; + } + } + + String toJSONValue() { + return this.mode; + } + + public String toString() { + return this.mode; + } + } +} diff --git a/src/main/java/com/box/sdk/BoxAIDialogueEntry.java b/src/main/java/com/box/sdk/BoxAIDialogueEntry.java new file mode 100644 index 000000000..1a6e00e6a --- /dev/null +++ b/src/main/java/com/box/sdk/BoxAIDialogueEntry.java @@ -0,0 +1,107 @@ +package com.box.sdk; + +import com.eclipsesource.json.JsonObject; +import java.util.Date; + + +/** + * Represents an entry of the history of prompts and answers previously passed to the LLM. + * This provides additional context to the LLM in generating the response. + */ +@BoxResourceType("file_version") +public class BoxAIDialogueEntry extends BoxJSONObject { + private String prompt; + private String answer; + private Date createdAt; + + /** + * + * @param prompt The prompt previously provided by the client and answered by the LLM. + * @param answer The answer previously provided by the LLM. + * @param createdAt The ISO date formatted timestamp of when the previous answer to the prompt was created. + */ + public BoxAIDialogueEntry(String prompt, String answer, Date createdAt) { + super(); + this.prompt = prompt; + this.answer = answer; + this.createdAt = createdAt; + } + + /** + * + * @param prompt The prompt previously provided by the client and answered by the LLM. + * @param answer The answer previously provided by the LLM. + */ + public BoxAIDialogueEntry(String prompt, String answer) { + super(); + this.prompt = prompt; + this.answer = answer; + } + + /** + * Get the answer previously provided by the LLM. + * @return the answer previously provided by the LLM. + */ + public String getAnswer() { + return answer; + } + + /** + * Set the answer previously provided by the LLM. + * @param answer the answer previously provided by the LLM. + */ + public void setAnswer(String answer) { + this.answer = answer; + } + + /** + * Get the prompt previously provided by the client and answered by the LLM. + * + * @return the prompt previously provided by the client and answered by the LLM. + */ + public String getPrompt() { + return prompt; + } + + /** + * Set the prompt previously provided by the client and answered by the LLM. + * + * @param prompt the prompt previously provided by the client and answered by the LLM. + */ + public void setPrompt(String prompt) { + this.prompt = prompt; + } + + /** + * Get The ISO date formatted timestamp of when the previous answer to the prompt was created. + * @return The ISO date formatted timestamp of when the previous answer to the prompt was created. + */ + public Date getCreatedAt() { + return createdAt; + } + + /** + * Set The ISO date formatted timestamp of when the previous answer to the prompt was created. + * @param createdAt The ISO date formatted timestamp of when the previous answer to the prompt was created. + */ + public void setCreatedAt(Date createdAt) { + this.createdAt = createdAt; + } + + /** + * Gets a JSON object representing this class. + * + * @return the JSON object representing this class. + */ + public JsonObject getJSONObject() { + JsonObject itemJSON = new JsonObject() + .add("id", this.prompt) + .add("type", this.answer); + + if (this.createdAt != null) { + itemJSON.add("content", this.createdAt.toString()); + } + + return itemJSON; + } +} diff --git a/src/main/java/com/box/sdk/BoxAIItem.java b/src/main/java/com/box/sdk/BoxAIItem.java new file mode 100644 index 000000000..15a4f387d --- /dev/null +++ b/src/main/java/com/box/sdk/BoxAIItem.java @@ -0,0 +1,121 @@ +package com.box.sdk; + +import com.eclipsesource.json.JsonObject; + +/** + * Represents a Box File to be included in a sign request. + */ +public class BoxAIItem { + private String id; + private Type type; + private String content; + + /** + * Created a BoxAIItem - the item to be processed by the LLM. + * @param id The id of the item + * @param type The type of the item. Currently, only "file" is supported. + * @param content The content of the item, often the text representation. + */ + public BoxAIItem(String id, Type type, String content) { + this.id = id; + this.type = type; + this.content = content; + } + + /** + * Created a BoxAIItem - the item to be processed by the LLM. + * @param id The id of the item + * @param type The type of the item. Currently, only "file" is supported. + */ + public BoxAIItem(String id, Type type) { + this.id = id; + this.type = type; + } + + /** + * Gets the id of the item. + * @return the id of the item. + */ + public String getId() { + return id; + } + + /** + * Sets the id of the item. + * @param id the id of the item. + */ + public void setId(String id) { + this.id = id; + } + + /** + * Gets the type of the item. + * @return the type of the item. + */ + public Type getType() { + return type; + } + + /** + * Sets the type of the item. + * @param type the type of the item. + */ + public void setType(Type type) { + this.type = type; + } + + /** + * Gets the content of the item. + * @return the content of the item. + */ + public String getContent() { + return content; + } + + /** + * Sets the content of the item. + * @param content the content of the item. + */ + public void setContent(String content) { + this.content = content; + } + + /** + * Gets a JSON object representing this class. + * + * @return the JSON object representing this class. + */ + public JsonObject getJSONObject() { + JsonObject itemJSON = new JsonObject() + .add("id", this.id) + .add("type", this.type.toJSONValue()); + + if (this.content != null) { + itemJSON.add("content", this.content); + } + + return itemJSON; + } + + public enum Type { + /** + * A file. + */ + FILE("file"); + + + private final String jsonValue; + + Type(String jsonValue) { + this.jsonValue = jsonValue; + } + + static BoxAIItem.Type fromJSONValue(String jsonValue) { + return BoxAIItem.Type.valueOf(jsonValue.toUpperCase()); + } + + String toJSONValue() { + return this.jsonValue; + } + } +} diff --git a/src/main/java/com/box/sdk/BoxAIResponse.java b/src/main/java/com/box/sdk/BoxAIResponse.java new file mode 100644 index 000000000..c563bdf53 --- /dev/null +++ b/src/main/java/com/box/sdk/BoxAIResponse.java @@ -0,0 +1,92 @@ +package com.box.sdk; + +import com.eclipsesource.json.JsonObject; +import com.eclipsesource.json.JsonValue; +import java.text.ParseException; +import java.util.Date; + + +/** + * AI response to a user request. + */ +public class BoxAIResponse extends BoxJSONObject { + private String answer; + private String completionReason; + private Date createdAt; + + /** + * Constructs a BoxAIResponse object. + */ + public BoxAIResponse(String answer, String completionReason, Date createdAt) { + super(); + this.answer = answer; + this.completionReason = completionReason; + this.createdAt = createdAt; + } + + /** + * Constructs a BoxAIResponse from a JSON string. + * + * @param json the JSON encoded upload email. + */ + public BoxAIResponse(String json) { + super(json); + } + + /** + * Constructs an BoxAIResponse object using an already parsed JSON object. + * + * @param jsonObject the parsed JSON object. + */ + BoxAIResponse(JsonObject jsonObject) { + super(jsonObject); + } + + /** + * Gets the answer of the AI. + * + * @return the answer of the AI. + */ + public String getAnswer() { + return answer; + } + + /** + * Gets reason the response finishes. + * + * @return the reason the response finishes. + */ + public String getCompletionReason() { + return completionReason; + } + + /** + * Gets the ISO date formatted timestamp of when the answer to the prompt was created. + * + * @return The ISO date formatted timestamp of when the answer to the prompt was created. + */ + public Date getCreatedAt() { + return createdAt; + } + + + /** + * {@inheritDoc} + */ + @Override + void parseJSONMember(JsonObject.Member member) { + JsonValue value = member.getValue(); + String memberName = member.getName(); + try { + if (memberName.equals("answer")) { + this.answer = value.asString(); + } else if (memberName.equals("completion_reason")) { + this.completionReason = value.asString(); + } else if (memberName.equals("created_at")) { + this.createdAt = BoxDateFormat.parse(value.asString()); + } + } catch (ParseException e) { + assert false : "A ParseException indicates a bug in the SDK."; + } + } +} diff --git a/src/test/Fixtures/BoxAI/SendAIRequest200.json b/src/test/Fixtures/BoxAI/SendAIRequest200.json new file mode 100644 index 000000000..1ec314c6a --- /dev/null +++ b/src/test/Fixtures/BoxAI/SendAIRequest200.json @@ -0,0 +1,5 @@ +{ + "answer": "Public APIs are important because of key and important reasons.", + "completion_reason": "done", + "created_at": "2012-12-12T10:53:43.123-08:00" +} \ No newline at end of file diff --git a/src/test/Fixtures/BoxAI/SendAITextGen200.json b/src/test/Fixtures/BoxAI/SendAITextGen200.json new file mode 100644 index 000000000..1ec314c6a --- /dev/null +++ b/src/test/Fixtures/BoxAI/SendAITextGen200.json @@ -0,0 +1,5 @@ +{ + "answer": "Public APIs are important because of key and important reasons.", + "completion_reason": "done", + "created_at": "2012-12-12T10:53:43.123-08:00" +} \ No newline at end of file diff --git a/src/test/java/com/box/sdk/BoxAITest.java b/src/test/java/com/box/sdk/BoxAITest.java new file mode 100644 index 000000000..3d125cc67 --- /dev/null +++ b/src/test/java/com/box/sdk/BoxAITest.java @@ -0,0 +1,120 @@ +package com.box.sdk; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.junit.WireMockRule; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import static com.box.sdk.http.ContentType.APPLICATION_JSON; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static java.lang.String.format; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +/** + * {@link BoxAI} related unit tests. + */ +public class BoxAITest { + + @Rule + public WireMockRule wireMockRule = new WireMockRule(wireMockConfig().dynamicHttpsPort().httpDisabled(true)); + private final BoxAPIConnection api = TestUtils.getAPIConnection(); + + @Before + public void setUpBaseUrl() { + api.setMaxRetryAttempts(1); + api.setBaseURL(format("https://localhost:%d", wireMockRule.httpsPort())); + } + + @Test + public void testsendAIRequestSuccess() { + final String fileId = "12345"; + final String prompt = "What is the name of the file?"; + + String result = TestUtils.getFixture("BoxAI/SendAIRequest200"); + wireMockRule.stubFor(WireMock.post(WireMock.urlPathEqualTo("/2.0/ai/ask")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", APPLICATION_JSON) + .withBody(result))); + + BoxAIResponse response = BoxAI.sendAIRequest( + api, + prompt, + Collections.singletonList(new BoxAIItem(fileId, BoxAIItem.Type.FILE)), + BoxAI.Mode.SINGLE_ITEM_QA + ); + + assertThat( + response.getAnswer(), equalTo("Public APIs are important because of key and important reasons.") + ); + assertThat(response.getCreatedAt(), equalTo(new Date(1355338423123L))); + assertThat(response.getCompletionReason(), equalTo("done")); + } + + @Test + public void testsendAITexGenRequestWithNoDialogueHistorySuccess() { + final String fileId = "12345"; + final String prompt = "What is the name of the file?"; + + String result = TestUtils.getFixture("BoxAI/SendAITextGen200"); + wireMockRule.stubFor(WireMock.post(WireMock.urlPathEqualTo("/2.0/ai/text_gen")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", APPLICATION_JSON) + .withBody(result))); + + BoxAIResponse response = BoxAI.sendAITextGenRequest( + api, + prompt, + Collections.singletonList(new BoxAIItem(fileId, BoxAIItem.Type.FILE)) + ); + + assertThat( + response.getAnswer(), equalTo("Public APIs are important because of key and important reasons.") + ); + assertThat(response.getCreatedAt(), equalTo(new Date(1355338423123L))); + assertThat(response.getCompletionReason(), equalTo("done")); + } + + @Test + public void testsendAITexGenRequestWithDialogueHistorySuccess() throws ParseException { + final String fileId = "12345"; + final String prompt = "What is the name of the file?"; + + Date date1 = BoxDateFormat.parse("2013-05-16T15:27:57-07:00"); + Date date2 = BoxDateFormat.parse("2013-05-16T15:26:57-07:00"); + + List dialogueHistory = new ArrayList<>(); + dialogueHistory.add( + new BoxAIDialogueEntry("What is the name of the file?", "Test file", date1) + ); + dialogueHistory.add( + new BoxAIDialogueEntry("What is the size of the file?", "10kb", date2) + ); + + String result = TestUtils.getFixture("BoxAI/SendAITextGen200"); + wireMockRule.stubFor(WireMock.post(WireMock.urlPathEqualTo("/2.0/ai/text_gen")) + .willReturn(WireMock.aResponse() + .withHeader("Content-Type", APPLICATION_JSON) + .withBody(result))); + + BoxAIResponse response = BoxAI.sendAITextGenRequest( + api, + prompt, + Collections.singletonList(new BoxAIItem(fileId, BoxAIItem.Type.FILE)), + dialogueHistory + ); + + assertThat( + response.getAnswer(), equalTo("Public APIs are important because of key and important reasons.") + ); + assertThat(response.getCreatedAt(), equalTo(new Date(1355338423123L))); + assertThat(response.getCompletionReason(), equalTo("done")); + } +} diff --git a/src/test/java/com/box/sdk/BoxDateFormatTest.java b/src/test/java/com/box/sdk/BoxDateFormatTest.java index 3b6c0cf8a..a1abd583d 100644 --- a/src/test/java/com/box/sdk/BoxDateFormatTest.java +++ b/src/test/java/com/box/sdk/BoxDateFormatTest.java @@ -57,6 +57,14 @@ public void testFormatOutputsZuluTimezone() { assertEquals(expectedString, BoxDateFormat.format(date)); } + @Test + public void testParseWorksWithMilisecondsResolution() throws ParseException { + + Date date = BoxDateFormat.parse("2019-04-06T22:58:49.123+01:00"); + Date expectedDate = new Date(1554587929123L); + assertEquals(expectedDate, date); + } + @Test public void testFormatDateToDateOnlyString() { Date date = Date.from(LocalDateTime.of(2020, 5, 14, 10, 15, 12) From 7b21395ed1e1241f62183687efc3dc7f386963a5 Mon Sep 17 00:00:00 2001 From: lsocha Date: Wed, 24 Apr 2024 16:54:36 +0200 Subject: [PATCH 2/5] Add empty endlines --- src/test/Fixtures/BoxAI/SendAIRequest200.json | 2 +- src/test/Fixtures/BoxAI/SendAITextGen200.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/Fixtures/BoxAI/SendAIRequest200.json b/src/test/Fixtures/BoxAI/SendAIRequest200.json index 1ec314c6a..070e51719 100644 --- a/src/test/Fixtures/BoxAI/SendAIRequest200.json +++ b/src/test/Fixtures/BoxAI/SendAIRequest200.json @@ -2,4 +2,4 @@ "answer": "Public APIs are important because of key and important reasons.", "completion_reason": "done", "created_at": "2012-12-12T10:53:43.123-08:00" -} \ No newline at end of file +} diff --git a/src/test/Fixtures/BoxAI/SendAITextGen200.json b/src/test/Fixtures/BoxAI/SendAITextGen200.json index 1ec314c6a..070e51719 100644 --- a/src/test/Fixtures/BoxAI/SendAITextGen200.json +++ b/src/test/Fixtures/BoxAI/SendAITextGen200.json @@ -2,4 +2,4 @@ "answer": "Public APIs are important because of key and important reasons.", "completion_reason": "done", "created_at": "2012-12-12T10:53:43.123-08:00" -} \ No newline at end of file +} From c3fcd9bb0d5e5f9bf8fa8f6c0e365dfcef4abeb4 Mon Sep 17 00:00:00 2001 From: lsocha Date: Tue, 30 Apr 2024 12:34:06 +0200 Subject: [PATCH 3/5] fix tests --- src/intTest/java/com/box/sdk/BoxAIIT.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/intTest/java/com/box/sdk/BoxAIIT.java b/src/intTest/java/com/box/sdk/BoxAIIT.java index e3f7953fe..a5d500da0 100644 --- a/src/intTest/java/com/box/sdk/BoxAIIT.java +++ b/src/intTest/java/com/box/sdk/BoxAIIT.java @@ -55,7 +55,7 @@ public void askAISingleItem() throws InterruptedException { BoxAI.Mode.SINGLE_ITEM_QA ); assertThat(response.getAnswer(), containsString("Test file")); - assert response.getCreatedAt().after(new Date(System.currentTimeMillis())); + assert response.getCreatedAt().before(new Date(System.currentTimeMillis())); assertThat(response.getCompletionReason(), equalTo("done")); }, 2, 2000); @@ -90,7 +90,7 @@ public void askAIMultipleItems() throws InterruptedException { BoxAI.Mode.MULTIPLE_ITEM_QA ); assertThat(response.getAnswer(), containsString("Test file")); - assert response.getCreatedAt().after(new Date(System.currentTimeMillis())); + assert response.getCreatedAt().before(new Date(System.currentTimeMillis())); assertThat(response.getCompletionReason(), equalTo("done")); }, 2, 2000); @@ -128,7 +128,7 @@ public void askAITextGenItemWithDialogueHistory() throws ParseException, Interru dialogueHistory ); assertThat(response.getAnswer(), containsString("name")); - assert response.getCreatedAt().after(new Date(System.currentTimeMillis())); + assert response.getCreatedAt().before(new Date(System.currentTimeMillis())); assertThat(response.getCompletionReason(), equalTo("done")); }, 2, 2000); From 39e3549195e1152c0ca9f93516516cafeeb8cc8a Mon Sep 17 00:00:00 2001 From: lsocha Date: Tue, 30 Apr 2024 14:15:40 +0200 Subject: [PATCH 4/5] code review fixes --- doc/ai.md | 10 ++--- src/intTest/java/com/box/sdk/BoxAIIT.java | 45 +++++++++++++---------- src/test/java/com/box/sdk/BoxAITest.java | 6 +-- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/doc/ai.md b/doc/ai.md index c60754af4..b21110f4e 100644 --- a/doc/ai.md +++ b/doc/ai.md @@ -15,13 +15,13 @@ an answer based on the provided prompt and items. Send AI request -------------------------- -To send an AI request to get an answer call static +To send an AI request, call static [`sendAIRequest(String prompt, List items, Mode mode)`][send-ai-request] method. In the request you have to provide a prompt, a list of items that your prompt refers to and a mode of the request. There are two modes available: `SINGLE_ITEM_QA` and `MULTI_ITEM_QA`, which specifies if this request refers to for a single or multiple items. - + ```java BoxAIResponse response = BoxAI.sendAIRequest( api, @@ -39,12 +39,12 @@ It usually takes a few seconds for the file to be indexed and available for the Send AI text generation request -------------- -To send an AI request to get an answer specifically focused on the creation of new text call static +To send an AI request specifically focused on the creation of new text, call static [`sendAITextGenRequest(String prompt, List items, List dialogueHistory)`][send-ai-text-gen-request] method. -In the request you have to provide a prompt, a list of items that your prompt refers to and a dialogue history, +In the request you have to provide a prompt, a list of items that your prompt refers to and optionally a dialogue history, which provides additional context to the LLM in generating the response. - + ```java List dialogueHistory = new ArrayList<>(); dialogueHistory.add( diff --git a/src/intTest/java/com/box/sdk/BoxAIIT.java b/src/intTest/java/com/box/sdk/BoxAIIT.java index a5d500da0..9fb181884 100644 --- a/src/intTest/java/com/box/sdk/BoxAIIT.java +++ b/src/intTest/java/com/box/sdk/BoxAIIT.java @@ -69,30 +69,35 @@ public void askAIMultipleItems() throws InterruptedException { BoxAPIConnection api = jwtApiForServiceAccount(); String fileName1 = "[askAIMultipleItems] Test File.txt"; BoxFile uploadedFile1 = uploadFileToUniqueFolder(api, fileName1, "Test file"); + try { String fileName2 = "[askAIMultipleItems] Weather forecast.txt"; BoxFile uploadedFile2 = uploadFileToUniqueFolder(api, fileName2, "Test file"); - BoxFile.Info uploadedFileInfo1 = uploadedFile1.getInfo(); - BoxFile.Info uploadedFileInfo2 = uploadedFile2.getInfo(); - - List items = new ArrayList<>(); - items.add(new BoxAIItem(uploadedFileInfo1.getID(), BoxAIItem.Type.FILE)); - items.add(new BoxAIItem(uploadedFileInfo2.getID(), BoxAIItem.Type.FILE)); - - // When a file has been just uploaded, AI service may not be ready to return text response - // and 412 is returned - retry(() -> { - BoxAIResponse response = BoxAI.sendAIRequest( - api, - "What is the content of these files?", - items, - BoxAI.Mode.MULTIPLE_ITEM_QA - ); - assertThat(response.getAnswer(), containsString("Test file")); - assert response.getCreatedAt().before(new Date(System.currentTimeMillis())); - assertThat(response.getCompletionReason(), equalTo("done")); - }, 2, 2000); + try { + BoxFile.Info uploadedFileInfo1 = uploadedFile1.getInfo(); + BoxFile.Info uploadedFileInfo2 = uploadedFile2.getInfo(); + + List items = new ArrayList<>(); + items.add(new BoxAIItem(uploadedFileInfo1.getID(), BoxAIItem.Type.FILE)); + items.add(new BoxAIItem(uploadedFileInfo2.getID(), BoxAIItem.Type.FILE)); + + // When a file has been just uploaded, AI service may not be ready to return text response + // and 412 is returned + retry(() -> { + BoxAIResponse response = BoxAI.sendAIRequest( + api, + "What is the content of these files?", + items, + BoxAI.Mode.MULTIPLE_ITEM_QA + ); + assertThat(response.getAnswer(), containsString("Test file")); + assert response.getCreatedAt().before(new Date(System.currentTimeMillis())); + assertThat(response.getCompletionReason(), equalTo("done")); + }, 2, 2000); + } finally { + deleteFile(uploadedFile2); + } } finally { deleteFile(uploadedFile1); diff --git a/src/test/java/com/box/sdk/BoxAITest.java b/src/test/java/com/box/sdk/BoxAITest.java index 3d125cc67..9bfc2ccc2 100644 --- a/src/test/java/com/box/sdk/BoxAITest.java +++ b/src/test/java/com/box/sdk/BoxAITest.java @@ -34,7 +34,7 @@ public void setUpBaseUrl() { } @Test - public void testsendAIRequestSuccess() { + public void testSendAIRequestSuccess() { final String fileId = "12345"; final String prompt = "What is the name of the file?"; @@ -59,7 +59,7 @@ public void testsendAIRequestSuccess() { } @Test - public void testsendAITexGenRequestWithNoDialogueHistorySuccess() { + public void testSendAITexGenRequestWithNoDialogueHistorySuccess() { final String fileId = "12345"; final String prompt = "What is the name of the file?"; @@ -83,7 +83,7 @@ public void testsendAITexGenRequestWithNoDialogueHistorySuccess() { } @Test - public void testsendAITexGenRequestWithDialogueHistorySuccess() throws ParseException { + public void testSendAITexGenRequestWithDialogueHistorySuccess() throws ParseException { final String fileId = "12345"; final String prompt = "What is the name of the file?"; From f134f8f853fd152f6d05ec3ef0f42f46981260c7 Mon Sep 17 00:00:00 2001 From: lsocha Date: Tue, 30 Apr 2024 14:41:01 +0200 Subject: [PATCH 5/5] remove rerunning itegration test on PR title edit --- .github/workflows/integration-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 2dca31131..e1e7d66ef 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -5,7 +5,6 @@ on: branches: - main pull_request: - types: [ opened, synchronize, edited ] branches: - main