diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..9283bb3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. +2. +3. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Environment** + +- Node.js version: +- acorn.js version: +- OS: + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..205ef6c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE] " +labels: enhancement +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000..fbd1c78 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,26 @@ +## Description + + + +## Related Issue + + + + +## Types of changes + + + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) + +## Checklist + + + +- [ ] My code follows the code style of this project +- [ ] My change requires a documentation update +- [ ] I have updated the documentation accordingly +- [ ] I have added tests to cover my changes +- [ ] All new and existing tests passed diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 586f40b..f32820d 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -26,6 +26,8 @@ jobs: ./mvnw -B org.apache.maven.plugins:maven-dependency-plugin:3.8.1:go-offline de.qaware.maven:go-offline-maven-plugin:1.2.8:resolve-dependencies - name: Build with Maven + env: + SPRING_AI_OPENAI_API_KEY: ${{ secrets.SPRING_AI_OPENAI_API_KEY }} run: ./mvnw -B test -Pci - name: Import GPG Key diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..59b19b8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,107 @@ +## Contributing to Acorn + +Thanks for your interest in DataSQRL's Acorn. + +# Contributions + +We welcome contributions from anyone. + +Submit a pull request and it will be reviewed by a contributor or committer in the project. The +contributor may ask for changes or information before being accepted. + +## Committers + +Committers for this project can be viewed on the Github project page. + +# Sign Your Work + +The _sign-off_ is a simple line at the end of the message for a commit. All commits need to be signed. +Your signature certifies that you wrote the patch or otherwise have the right to contribute the material +(see [Developer Certificate of Origin](https://developercertificate.org)): + +``` +This is my commit message + +Signed-off-by: John Doe +``` + +Git has a [`-s`](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---signoff) command line option to +append this automatically to your commit message: + +```bash +$ git commit -s -m "This is my commit message" +``` + +Unfortunately, anyone with write access to a repository can easily impersonate another user. +Consider how each commit is associated with a user via their email address. +There's nothing stopping someone from using someone else's email address to make commits. +The issue extends to the signoff message as well. + +That's why it's advisable to sign your commits with a unique key. Git offers support for various types of keys, +and this time, we'll walk you through signing your commits using GPG. + +#### Setup Git using GPG + +Ensure that gpg is installed on your system. + +On MacOS: + +```bash +brew install gpg +``` + +Generate your key: + +```bash +gpg --full-generate-key +``` + +Recommended settings: + +- **key kind:** (1) RSA and RSA +- **key size:** 4096 +- **key validity:** key does not expire + (you can revoke keys, so unless you don't lose access to your key it is more convenient) +- **real name:** it is recommended using your real name +- **email address:** it is recommended to use the same email address here that you use to commit your work +- **comment:** it is recommended to use different keys for different use-cases / organizations. + If you use the same email across organizations, you can distinguish your keys with the help of this field. + eg.: "CODE SIGNING KEY" or "ACORN CODE SIGNING KEY" + +You can create the new key by selecting "(O)kay" + +To view your key, you issue this command: + +```bash +gpg --list-secret-keys --keyid-format=long +``` + +The output should look like this: + +``` +sec rsa4096/D2A162EAE1016F3G 2024-04-05 [SC] + AFB8C2DEFEA93470D81C84E7D2A162EAE1016F3G +uid [ultimate] John Doe (CODE SIGNING KEY) +ssb rsa4096/2F7B9EAC4D6F8150 2024-04-05 [E] +``` + +To use the above key to sign your commits cd into a repository and issue these commands: + +```bash +git config user.signingkey D2A162EAE1016F3G +git config commit.gpgsign true +``` + +You also need to add the public key to your github profile for the signing to be verified. + +To do so, go to your github settings page, select the `SSH and GPG keys` tab. + +Press `New GPG Key`, then enter a name for the key and the outputs of the following command. + +``` +gpg --armor --export D2A162EAE1016F3G +``` + +## License + +By contributing to Acorn, you agree that your contributions will be licensed under the Apache License 2.0. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 288991d..0000000 --- a/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM maven:3.8.5-openjdk-17 as builder -WORKDIR /app - -COPY pom.xml . -COPY acorn-graphql acorn-graphql -COPY acorn-core acorn-core -COPY acorn-openai acorn-openai -COPY acorn-bedrock acorn-bedrock -COPY acorn-groq acorn-groq -COPY acorn-vertex acorn-vertex -COPY acorn-docker acorn-docker - -RUN mvn clean package -DskipTests - -FROM openjdk:17-slim -WORKDIR /app - -COPY --from=builder /app/acorn-spring/target/*.jar server.jar - -COPY server-start.sh /app -RUN chmod +x /app/server-start.sh - -ENTRYPOINT ["/app/server-start.sh"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index bfb8873..8ad7942 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,31 @@ -# Java Implementation of Acorn Agent +# Acorn-Java: LLM Tooling from GraphQL APIs -This is the Java implementation of Acorn Agent which provides the libraries you need to implement an AI agent on the JVM. +Acorn is a lightweight library that analyzes GraphQL APIs/queries and creates LLM tools from the available endpoints. Use Acorn to enable chatbots, agents, and other LLM applications to call GraphQL APIs for data retrieval and persistence. + +Acorn provides the following features: + +- Converts GraphQL Schemas (introspected or provided) to LLM tools (i.e. tooling definitions in JSON schema) that are supported by all popular LLMs (OpenAI, Anthropic, Google, Llama, etc.) +- Direct integration with LLM SDKs and agentic frameworks (e.g. Spring AI) +- Manages tool calling, schema validation, and data fetching +- Supports chat history persistence and retrieval through GraphQL API for Chatbots +- Extensible and modular to adjust it to your needs (e.g. bring your own query executor) +- Sandboxing for sensitive information (e.g. secrets, session ids) that cannot be passed to LLMs" + +In short, Acorn eliminates boilerplate code when building chatbots and agents that interact with APIs. It integrates GraphQL APIs with LLMs in a small library that you can extend to suit your needs. + +![Acorn](img/acorn_diagram.svg) ## Getting Started ### Spring Boot -To build an AI agent as a web application with Spring Boot, include the following dependency in your project and use the generic implementation in [AcornAgentServer](acorn-spring/src/main/java/com/datasqrl/ai/spring/AcornAgentServer.java) as a starting point for your own. +To build an AI agent as a web application with Spring Boot, include the following dependency in your project and use the generic implementation in [SpringAITestApplication](acorn-springai/src/main/java/com/datasqrl/ai/spring/SpringAITestApplication.java) as a starting point for your own. #### Gradle ```text dependencies { - implementation 'com.datasqrl:acorn-spring:0.1.0' + implementation 'com.datasqrl:acorn-springai:0.1.0' } ``` @@ -21,7 +34,7 @@ dependencies { ```text com.datasqrl - acorn-spring + acorn-springai 0.1.0 ``` @@ -34,7 +47,7 @@ If you prefer a different web development framework or want to use Acorn Agent i ```text dependencies { - implementation 'com.datasqrl:acorn-starter:0.1.0' + implementation 'com.datasqrl:acorn-graphql:0.1.0' } ``` @@ -43,45 +56,21 @@ dependencies { ```text com.datasqrl - acorn-starter + acorn-graphql 0.1.0 ``` -## Modules +## Documentation -The implementation is divided into multiple modules, so you can include exactly the modules you need in your agent implementation without pulling in unneeded dependencies. +The base implementation of Acorn can be found in the [acorn-graphql](acorn-graphql) module. See the [documentation](acorn-graphql/README.md) of that module for more information. -* **[acorn-core](acorn-core/)**: The core module contains the implementation of the `ToolsBackend`, `ChatSession`, `ChatProvider`, and model configuration. Those are the core components of Acorn Agent. -* **[acorn-udf](acorn-udf)**: Adds support for user defined functions. Add this module if you want to add a tool that executes locally by invoking a function. -* **[acorn-openai](acorn-openai)**: Implements OpenAI as a model provider with support for the GPT-class of large-language models. -* **[acorn-bedrock](acorn-bedrock)**: Implements AWS Bedrock as a model provider with support for LLMs like Llama3 and others. -* **[acorn-vertex](acorn-vertex)**: Implements Google Vertex as a model provider with support for Gemini-class of LLMs and others. -* **[acorn-groq](acorn-groq)**: Implements Groq as a model provider with support for models like Llama3, Mixtral, etc. -* **[acorn-graphql](acorn-graphql)**: Supports mapping GraphQL schemas to tool configurations to simplify integration of GraphQL APIs as a tool. -* **[acorn-rest](acorn-rest)**: Utilities for calling REST APIs as a tool. Supports mapping OpenAPI schemas to tool configurations to simplify integration of REST APIs. -* **[acorn-config](acorn-config)**: Utilities for file-based configuration of Acorn Agent and it's components (i.e. models, provider, tools, and APIs) -* **[acorn-starter](acorn-starter)**: Sample API executors and example implementations of AI agents. -* **[acorn-spring](acorn-spring)**: Integrates Acorn Agent with the Spring Boot web development framework. +The [acorn-springai](acorn-springai) module integrates Acorn with Spring AI. See the [documentation](acorn-springai/README.md) of that module for more information. -To include a module into your project, use a dependency management tool like Gradle or Maven as follows. Replace `[MODULE_NAME]` with the name of the module and configure `{acorn.version}` to the current version of Acorn Agent. +## Examples -### Gradle +There are a few examples available under [acorn-examples](acorn-examples) module. -```text title='Gradle' -dependencies { - implementation 'com.datasqrl:acorn-[MODULE_NAME]:${acorn.version}' -} -``` - -### Maven +## Contributing -```text title='Maven' - - - com.datasqrl - acorn-[MODULE_NAME] - ${acorn.version} - - -``` \ No newline at end of file +We love contributions. Open an issue if you encounter a bug or have a feature request. See [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. diff --git a/acorn-examples/acorn-example-rick-and-morty/README.md b/acorn-examples/acorn-example-rick-and-morty/README.md new file mode 100644 index 0000000..3f808e5 --- /dev/null +++ b/acorn-examples/acorn-example-rick-and-morty/README.md @@ -0,0 +1,60 @@ +# 🧠 Acorn Java Example: Rick and Morty NLQ + +This is a minimal example of using [Acorn Java](https://github.com/DataSQRL/acorn-java) to query the [Rick and Morty API](https://rickandmortyapi.com/) using **natural language**. + +Acorn Java is a Java-native framework that allows you to convert natural language into executable GraphQL queries, powered by large language models (LLMs) and Acorn's schema understanding. + +--- + +## 💡 What This Project Does + +This example shows how to: + +- Use **Acorn Java** to translate natural language into GraphQL queries +- Connect to the [Rick and Morty GraphQL API](https://rickandmortyapi.com/documentation/#graphql) +- Print the result of executing natural queries like: + + > "Show me all episodes where Morty appears" + > "List all characters from Earth" + > "What are the names of the planets in the show?" + +--- + +## 🛠️ How It Works + +1. **Schema Introspection** + Acorn introspects the Rick and Morty GraphQL schema and builds a natural language understanding of the available types and fields. + +2. **Natural Language to Query Translation** + User inputs are passed to Acorn, which uses an LLM to generate a valid GraphQL query. + +3. **Query Execution** + The query is sent to the Rick and Morty API, and the results are printed to the console. + +--- + +## 🚀 Getting Started + +### Prerequisites + +- Java 17+ +- OpenAI API key (used via environment variable `OPENAI_API_KEY`) + +### Run the example + +```bash +mvn spring-boot:run +``` + +Then query the API: + +``` +curl --location 'http://localhost:8080/agent?message=List all characters who appeared in episode Pilot' +``` + + +## 🤖 Example Queries + +> List all characters who appeared in episode "Pilot" +> Show me the episodes where Rick goes to another dimension +> What species are there in the show? diff --git a/acorn-examples/acorn-example-rick-and-morty/pom.xml b/acorn-examples/acorn-example-rick-and-morty/pom.xml new file mode 100644 index 0000000..1f6df84 --- /dev/null +++ b/acorn-examples/acorn-example-rick-and-morty/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + com.datasqrl.acorn + acorn-examples + 0.2-SNAPSHOT + + + acorn-example-rick-and-morty + Acorn - Example - Rick and Morty API + + https://rickandmortyapi.com/documentation/ + + + + com.datasqrl.acorn + acorn-springai + 0.2-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-test + ${spring.version} + test + + + com.fasterxml.jackson.module + jackson-module-jsonSchema + ${jackson.version} + + + + org.springframework.boot + spring-boot-starter-web + ${spring.version} + + + + org.springframework.boot + spring-boot-starter-actuator + ${spring.version} + + + + org.springframework.ai + spring-ai-openai-spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-test + ${spring.version} + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/acorn-examples/acorn-example-rick-and-morty/src/main/java/com/datasqrl/ai/example/AIController.java b/acorn-examples/acorn-example-rick-and-morty/src/main/java/com/datasqrl/ai/example/AIController.java new file mode 100644 index 0000000..08dbbe2 --- /dev/null +++ b/acorn-examples/acorn-example-rick-and-morty/src/main/java/com/datasqrl/ai/example/AIController.java @@ -0,0 +1,24 @@ +package com.datasqrl.ai.example; + +import java.util.Map; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** An example endpoint for message completion */ +@RestController +class AIController { + + private final ChatClient chatClient; + + AIController(ChatClient chatClient) { + this.chatClient = chatClient; + } + + @GetMapping("/agent") + Map prompt( + @RequestParam(value = "prompt", defaultValue = "What can you help me with?") String prompt) { + return Map.of("completion", chatClient.prompt().user(prompt).call().content()); + } +} diff --git a/acorn-examples/acorn-example-rick-and-morty/src/main/java/com/datasqrl/ai/example/Config.java b/acorn-examples/acorn-example-rick-and-morty/src/main/java/com/datasqrl/ai/example/Config.java new file mode 100644 index 0000000..5cfe810 --- /dev/null +++ b/acorn-examples/acorn-example-rick-and-morty/src/main/java/com/datasqrl/ai/example/Config.java @@ -0,0 +1,105 @@ +package com.datasqrl.ai.example; + +import static com.datasqrl.ai.converter.GraphQLSchemaConverterConfig.ignorePrefix; + +import com.datasqrl.ai.acorn.AcornSpringAIUtils; +import com.datasqrl.ai.acorn.SpringGraphQLExecutor; +import com.datasqrl.ai.chat.ChatPersistence; +import com.datasqrl.ai.converter.GraphQLSchemaConverter; +import com.datasqrl.ai.converter.GraphQLSchemaConverterConfig; +import com.datasqrl.ai.converter.StandardAPIFunctionFactory; +import com.datasqrl.ai.tool.Context; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.MissingNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import graphql.com.google.common.collect.Iterators; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import lombok.NonNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +/** + * An example configuration for loading the GraphQL schema and message storage and retrieval queries + * to use Acorn with Chat persistence. + * + *

Uses `id` as the default context key. + */ +@Configuration +class Config { + private final ResourceLoader resourceLoader; + + public Config(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + private Resource getResourceFromClasspath(String path) { + return resourceLoader.getResource("classpath:" + path); + } + + private String loadResourceFileAsString(String path) { + return AcornSpringAIUtils.loadResourceAsString(getResourceFromClasspath(path)); + } + + @Bean + GraphQLSchemaConverter graphQLSchemaConverter(SpringGraphQLExecutor apiExecutor) { + return new GraphQLSchemaConverter( + loadResourceFileAsString("schema.graphql"), + GraphQLSchemaConverterConfig.builder().operationFilter(ignorePrefix("Internal")).build(), + new StandardAPIFunctionFactory(apiExecutor, Set.of())); + } + + @Bean + ChatPersistence inMemoryChat(ObjectMapper mapper) { + var messages = new ArrayList(); + return new ChatPersistence() { + + @Override + public CompletableFuture saveChatMessage( + @NonNull Object message, @NonNull Context context) { + messages.add(message); + return CompletableFuture.completedFuture("OK"); + } + + @Override + public List getChatMessages( + @NonNull Context context, int limit, @NonNull Class clazz) + throws IOException { + ObjectNode arguments = mapper.createObjectNode(); + arguments.put("limit", limit); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + + JsonNode root = mapper.valueToTree(messages); + JsonNode messages = + Optional.ofNullable(Iterators.getOnlyElement(root.path("data").fields(), null)) + .map(Map.Entry::getValue) + .orElse(MissingNode.getInstance()); + + List chatMessages = new ArrayList<>(); + for (JsonNode node : messages) { + ChatMessage chatMessage = mapper.treeToValue(node, clazz); + chatMessages.add(chatMessage); + } + Collections.reverse(chatMessages); // newest should be last + return chatMessages; + } + + @Override + public Set getMessageContextKeys() { + return Set.of(); + } + }; + } +} diff --git a/acorn-examples/acorn-example-rick-and-morty/src/main/java/com/datasqrl/ai/example/RickAndMortyExampleApplication.java b/acorn-examples/acorn-example-rick-and-morty/src/main/java/com/datasqrl/ai/example/RickAndMortyExampleApplication.java new file mode 100644 index 0000000..4b9ee15 --- /dev/null +++ b/acorn-examples/acorn-example-rick-and-morty/src/main/java/com/datasqrl/ai/example/RickAndMortyExampleApplication.java @@ -0,0 +1,14 @@ +package com.datasqrl.ai.example; + +import com.datasqrl.ai.acorn.EnableAcorn; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@EnableAcorn +public class RickAndMortyExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(RickAndMortyExampleApplication.class, args); + } +} diff --git a/acorn-examples/acorn-example-rick-and-morty/src/main/resources/application.properties b/acorn-examples/acorn-example-rick-and-morty/src/main/resources/application.properties new file mode 100644 index 0000000..db7a540 --- /dev/null +++ b/acorn-examples/acorn-example-rick-and-morty/src/main/resources/application.properties @@ -0,0 +1,2 @@ +config.backend-url=https://rickandmortyapi.com/graphql +spring.ai.openai.chat.options.model=gpt-4.5-preview diff --git a/acorn-springai/src/main/resources/logback-spring.xml b/acorn-examples/acorn-example-rick-and-morty/src/main/resources/logback-spring.xml similarity index 100% rename from acorn-springai/src/main/resources/logback-spring.xml rename to acorn-examples/acorn-example-rick-and-morty/src/main/resources/logback-spring.xml diff --git a/acorn-examples/acorn-example-rick-and-morty/src/main/resources/schema.graphql b/acorn-examples/acorn-example-rick-and-morty/src/main/resources/schema.graphql new file mode 100644 index 0000000..93ba562 --- /dev/null +++ b/acorn-examples/acorn-example-rick-and-morty/src/main/resources/schema.graphql @@ -0,0 +1,224 @@ + type Query { + """ + Get a specific character by ID + """ + character(id: ID!): Character + + """ + Get the list of all characters + """ + characters(page: Int, filter: FilterCharacter): Characters + + """ + Get a list of characters selected by ids + """ + charactersByIds(ids: [ID!]!): [Character] + + """ + Get a specific locations by ID + """ + location(id: ID!): Location + + """ + Get the list of all locations + """ + locations(page: Int, filter: FilterLocation): Locations + + """ + Get a list of locations selected by ids + """ + locationsByIds(ids: [ID!]!): [Location] + + """ + Get a specific episode by ID + """ + episode(id: ID!): Episode + + """ + Get the list of all episodes + """ + episodes(page: Int, filter: FilterEpisode): Episodes + + """ + Get a list of episodes selected by ids + """ + episodesByIds(ids: [ID!]!): [Episode] + } + + type Characters { + info: Info + results: [Character] + } + + type Locations { + info: Info + results: [Location] + } + + type Episodes { + info: Info + results: [Episode] + } + + type Character { + """ + The id of the character. + """ + id: ID + + """ + The name of the character. + """ + name: String + + """ + The status of the character ('Alive', 'Dead' or 'unknown'). + """ + status: String + + """ + The species of the character. + """ + species: String + + """ + The type or subspecies of the character. + """ + type: String + + """ + The gender of the character ('Female', 'Male', 'Genderless' or 'unknown'). + """ + gender: String + + """ + The character's origin location + """ + origin: Location + + """ + The character's last known location + """ + location: Location + + """ + Link to the character's image. + All images are 300x300px and most are medium shots or portraits since they are intended to be used as avatars. + """ + image: String + + """ + Episodes in which this character appeared. + """ + episode: [Episode]! + + """ + Time at which the character was created in the database. + """ + created: String + } + + type Location { + """ + The id of the location. + """ + id: ID + + """ + The name of the location. + """ + name: String + + """ + The type of the location. + """ + type: String + + """ + The dimension in which the location is located. + """ + dimension: String + + """ + List of characters who have been last seen in the location. + """ + residents: [Character]! + + """ + Time at which the location was created in the database. + """ + created: String + } + + type Episode { + """ + The id of the episode. + """ + id: ID + + """ + The name of the episode. + """ + name: String + + """ + The air date of the episode. + """ + air_date: String + + """ + The code of the episode. + """ + episode: String + + """ + List of characters who have been seen in the episode. + """ + characters: [Character]! + + """ + Time at which the episode was created in the database. + """ + created: String + } + + type Info { + """ + The length of the response. + """ + count: Int + + """ + The amount of pages. + """ + pages: Int + + """ + Number of the next page (if it exists) + """ + next: Int + + """ + Number of the previous page (if it exists) + """ + prev: Int + } + + input FilterCharacter { + name: String + status: String + species: String + type: String + gender: String + } + + input FilterLocation { + name: String + type: String + dimension: String + } + + input FilterEpisode { + name: String + episode: String + } \ No newline at end of file diff --git a/acorn-examples/acorn-example-rick-and-morty/src/test/java/com/datasqrl/ai/example/RickAndMortyExampleApplicationTest.java b/acorn-examples/acorn-example-rick-and-morty/src/test/java/com/datasqrl/ai/example/RickAndMortyExampleApplicationTest.java new file mode 100644 index 0000000..f95ac7e --- /dev/null +++ b/acorn-examples/acorn-example-rick-and-morty/src/test/java/com/datasqrl/ai/example/RickAndMortyExampleApplicationTest.java @@ -0,0 +1,39 @@ +package com.datasqrl.ai.example; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class RickAndMortyExampleApplicationTest { + + @LocalServerPort private int port; + + @Test + void givenId_whenQueryingLLM_thenGetAnswer() throws Exception { + String url = + "http://localhost:" + + port + + "/agent?prompt=List all characters who appeared in episode Pilot"; + RestTemplate restTemplate = new RestTemplate(); + + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + // Assert that the status code is 200 OK + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Assert that the response body is not null and contains expected JSON content + String responseBody = response.getBody(); + + assertThat(responseBody).isNotNull().contains("Rick Sanchez"); + + var json = new ObjectMapper().readTree(responseBody); + assertThat(json.has("completion")).as("JSON must contain a 'completion' field").isTrue(); + } +} diff --git a/acorn-examples/acorn-sqrl-creditcard-rewards/pom.xml b/acorn-examples/acorn-sqrl-creditcard-rewards/pom.xml new file mode 100644 index 0000000..1487fe0 --- /dev/null +++ b/acorn-examples/acorn-sqrl-creditcard-rewards/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + com.datasqrl.acorn + acorn-examples + 0.2-SNAPSHOT + + + acorn-sqrl-creditcard-rewards + Acorn - Example - SQRL credit card rewards + + https://github.com/DataSQRL/datasqrl-examples/blob/main/finance-credit-card-chatbot/package-rewards-local.json + + + + com.datasqrl.acorn + acorn-springai + 0.2-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-test + ${spring.version} + test + + + com.fasterxml.jackson.module + jackson-module-jsonSchema + ${jackson.version} + + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + testcontainers + test + + + + org.springframework.boot + spring-boot-starter-web + ${spring.version} + + + + org.springframework.boot + spring-boot-starter-actuator + ${spring.version} + + + + org.springframework.ai + spring-ai-openai-spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-test + ${spring.version} + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/acorn-springai/src/main/java/com/datasqrl/ai/spring/AIController.java b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/java/com/datasqrl/ai/example/AIController.java similarity index 92% rename from acorn-springai/src/main/java/com/datasqrl/ai/spring/AIController.java rename to acorn-examples/acorn-sqrl-creditcard-rewards/src/main/java/com/datasqrl/ai/example/AIController.java index edb05e6..e999855 100644 --- a/acorn-springai/src/main/java/com/datasqrl/ai/spring/AIController.java +++ b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/java/com/datasqrl/ai/example/AIController.java @@ -1,4 +1,4 @@ -package com.datasqrl.ai.spring; +package com.datasqrl.ai.example; import java.util.Map; import org.springframework.ai.chat.client.ChatClient; @@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +/** An example endpoint for message completion */ @RestController class AIController { diff --git a/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/java/com/datasqrl/ai/example/Config.java b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/java/com/datasqrl/ai/example/Config.java new file mode 100644 index 0000000..92a03d4 --- /dev/null +++ b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/java/com/datasqrl/ai/example/Config.java @@ -0,0 +1,60 @@ +package com.datasqrl.ai.example; + +import static com.datasqrl.ai.converter.GraphQLSchemaConverterConfig.ignorePrefix; + +import com.datasqrl.ai.acorn.AcornSpringAIUtils; +import com.datasqrl.ai.acorn.SpringGraphQLExecutor; +import com.datasqrl.ai.api.GraphQLQuery; +import com.datasqrl.ai.chat.APIChatPersistence; +import com.datasqrl.ai.chat.ChatPersistence; +import com.datasqrl.ai.converter.GraphQLSchemaConverter; +import com.datasqrl.ai.converter.GraphQLSchemaConverterConfig; +import com.datasqrl.ai.converter.StandardAPIFunctionFactory; +import java.util.Set; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +/** + * An example configuration for loading the GraphQL schema and message storage and retrieval queries + * to use Acorn with Chat persistence. + * + *

Uses `customerid` as the default context key. + */ +@Configuration +class Config { + + public static final String USERID_KEY = "chat_memory_conversation_userid"; + + private final ResourceLoader resourceLoader; + + public Config(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + private Resource getResourceFromClasspath(String path) { + return resourceLoader.getResource("classpath:" + path); + } + + private String loadResourceFileAsString(String path) { + return AcornSpringAIUtils.loadResourceAsString(getResourceFromClasspath(path)); + } + + @Bean + GraphQLSchemaConverter graphQLSchemaConverter(SpringGraphQLExecutor apiExecutor) { + return new GraphQLSchemaConverter( + loadResourceFileAsString("tools/schema.graphqls"), + GraphQLSchemaConverterConfig.builder().operationFilter(ignorePrefix("Internal")).build(), + new StandardAPIFunctionFactory(apiExecutor, Set.of("customerid"))); + } + + @Bean + ChatPersistence chatPersistence(SpringGraphQLExecutor apiExecutor) { + return new APIChatPersistence( + apiExecutor, + new GraphQLQuery(loadResourceFileAsString("memory/saveMessage.graphql")), + new GraphQLQuery(loadResourceFileAsString("memory/getMessage.graphql")), + Set.of(USERID_KEY)); + } +} diff --git a/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/java/com/datasqrl/ai/example/CreditcardRewardsExampleApplication.java b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/java/com/datasqrl/ai/example/CreditcardRewardsExampleApplication.java new file mode 100644 index 0000000..47188e7 --- /dev/null +++ b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/java/com/datasqrl/ai/example/CreditcardRewardsExampleApplication.java @@ -0,0 +1,14 @@ +package com.datasqrl.ai.example; + +import com.datasqrl.ai.acorn.EnableAcorn; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@EnableAcorn +public class CreditcardRewardsExampleApplication { + + public static void main(String[] args) { + SpringApplication.run(CreditcardRewardsExampleApplication.class, args); + } +} diff --git a/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/application.properties b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/application.properties new file mode 100644 index 0000000..6091aee --- /dev/null +++ b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/application.properties @@ -0,0 +1 @@ +config.backend-url=http://localhost:8888/graphql diff --git a/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/logback-spring.xml b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3bc32da --- /dev/null +++ b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/logback-spring.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/acorn-springai/src/main/resources/memory/getMessage.graphql b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/memory/getMessage.graphql similarity index 100% rename from acorn-springai/src/main/resources/memory/getMessage.graphql rename to acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/memory/getMessage.graphql diff --git a/acorn-springai/src/main/resources/memory/saveMessage.graphql b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/memory/saveMessage.graphql similarity index 100% rename from acorn-springai/src/main/resources/memory/saveMessage.graphql rename to acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/memory/saveMessage.graphql diff --git a/acorn-springai/src/main/resources/tools/schema.graphqls b/acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/tools/schema.graphqls similarity index 100% rename from acorn-springai/src/main/resources/tools/schema.graphqls rename to acorn-examples/acorn-sqrl-creditcard-rewards/src/main/resources/tools/schema.graphqls diff --git a/acorn-examples/acorn-sqrl-creditcard-rewards/src/test/java/com/datasqrl/ai/example/CreditcardRewardsExampleApplicationTest.java b/acorn-examples/acorn-sqrl-creditcard-rewards/src/test/java/com/datasqrl/ai/example/CreditcardRewardsExampleApplicationTest.java new file mode 100644 index 0000000..5e65834 --- /dev/null +++ b/acorn-examples/acorn-sqrl-creditcard-rewards/src/test/java/com/datasqrl/ai/example/CreditcardRewardsExampleApplicationTest.java @@ -0,0 +1,62 @@ +package com.datasqrl.ai.example; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestTemplate; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class CreditcardRewardsExampleApplicationTest { + + @LocalServerPort private int port; + + // Start the Docker container with the specified image and configuration + @SuppressWarnings("resource") + @Container + private static final GenericContainer cloudBackendContainer = + new GenericContainer<>(DockerImageName.parse("datasqrl/examples:finance")) + .withEnv("KAFKA_BOOTSTRAP_SERVER", "localhost:9092") + .withEnv("KAFKA_CONSUMER_GROUP", "cloud-backend1") + .withEnv("TZ", "UTC") + .withExposedPorts(8888, 8081, 9092) + .withCommand("run -c package-rewards-local.json"); + + @DynamicPropertySource + static void overrideProperties(DynamicPropertyRegistry registry) { + registry.add( + "config.backend-url", + () -> "http://localhost:" + cloudBackendContainer.getMappedPort(8888) + "/graphql"); + } + + @Test + void givenCustomerId_whenQueryingLLM_thenGetAnswer() throws Exception { + String url = "http://localhost:" + port + "/agent/1"; + RestTemplate restTemplate = new RestTemplate(); + + // Perform HTTP GET request to the /agent/1 endpoint + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + // Assert that the status code is 200 OK + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Assert that the response body is not null and contains expected JSON content + String responseBody = response.getBody(); + + assertThat(responseBody).isNotNull(); + + var json = new ObjectMapper().readTree(responseBody); + assertThat(json.has("completion")).as("JSON must contain a 'completion' field").isTrue(); + } +} diff --git a/acorn-examples/pom.xml b/acorn-examples/pom.xml new file mode 100644 index 0000000..28c4953 --- /dev/null +++ b/acorn-examples/pom.xml @@ -0,0 +1,19 @@ + + + 4.0.0 + + com.datasqrl.acorn + acorn-parent + 0.2-SNAPSHOT + + + acorn-examples + pom + Acorn - Examples + + + acorn-sqrl-creditcard-rewards + acorn-example-rick-and-morty + + + diff --git a/acorn-graphql/README.md b/acorn-graphql/README.md index ced9817..d7b93e1 100644 --- a/acorn-graphql/README.md +++ b/acorn-graphql/README.md @@ -1,5 +1,16 @@ -# GraphQL API +# Acorn GraphQL -This module contains the [GraphQLSchemaConverter](src/main/java/com/datasqrl/ai/api/GraphQLSchemaConverter.java) which converts a GraphQL schema file with documentation to a collection of tools for Acorn Agent. +This module contains the core implementation classes and interfaces of Acorn. This base module is used by framework specific modules and can be used as the basis for specific implementations for frameworks or LLMs. + +Specific implementation that utilize this module need to implement two things: +1. An implementation of `APIExecutor` for executing GraphQL queries +2. Passing the generated `APIFunction` to the LLM and validating/executing them when invoked by the LLM. This usually requires a thin wrapper based on the LLM framework or SDK used. + +This module consists of the following packages: + +* [Tool](src/main/java/com/datasqrl/ai/tool): Defines the `APIFunction` class which represents and LLM tool and how it maps to GraphQL API queries. Also defines `Context` for sensitive information sandboxing. +* [Converter](src/main/java/com/datasqrl/ai/converter): Converts a provided GraphQL schema or individual GraphQL operations to `APIFunction`. +* [API](src/main/java/com/datasqrl/ai/api): Interfaces and methods for API invocation. +* [Chat](src/main/java/com/datasqrl/ai/chat): Saving and retrieving messages from GraphQL API. +* [Util](src/main/java/com/datasqrl/ai/util): Utility classes/methods used across packages. -It also contains utilities for using GraphQL APIs as tools. \ No newline at end of file diff --git a/acorn-graphql/pom.xml b/acorn-graphql/pom.xml index 59e82fa..5249672 100644 --- a/acorn-graphql/pom.xml +++ b/acorn-graphql/pom.xml @@ -23,11 +23,6 @@ jackson-databind ${jackson.version} - - com.fasterxml.jackson.module - jackson-module-jsonSchema - ${jackson.version} - @@ -47,14 +42,16 @@ ${slf4j.version} test - + + org.assertj + assertj-core + 3.26.3 + test + + - - org.sonatype.central - central-publishing-maven-plugin - org.apache.maven.plugins maven-javadoc-plugin diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/api/GraphQLQuery.java b/acorn-graphql/src/main/java/com/datasqrl/ai/api/GraphQLQuery.java index 61860c9..efef347 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/api/GraphQLQuery.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/api/GraphQLQuery.java @@ -1,3 +1,8 @@ package com.datasqrl.ai.api; +/** + * Default GraphQL query implementation + * + * @param query query string + */ public record GraphQLQuery(String query) implements APIQuery {} diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/chat/APIChatPersistence.java b/acorn-graphql/src/main/java/com/datasqrl/ai/chat/APIChatPersistence.java index c576952..ccef51d 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/chat/APIChatPersistence.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/chat/APIChatPersistence.java @@ -30,7 +30,7 @@ public class APIChatPersistence implements ChatPersistence { APIQueryExecutor apiExecutor; APIQuery saveMessage; APIQuery getMessages; - Set getMessageContextKeys; + Set messageContextKeys; /** * Saves the generic chat message with the configured context asynchronously (i.e. does not block) @@ -76,7 +76,7 @@ public List getChatMessages( ObjectNode arguments = mapper.createObjectNode(); arguments.put("limit", limit); JsonNode variables = - FunctionUtil.addOrOverrideContext(arguments, getMessageContextKeys, context, mapper); + FunctionUtil.addOrOverrideContext(arguments, messageContextKeys, context, mapper); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/chat/ChatPersistence.java b/acorn-graphql/src/main/java/com/datasqrl/ai/chat/ChatPersistence.java index b4d5b95..0bf60db 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/chat/ChatPersistence.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/chat/ChatPersistence.java @@ -3,9 +3,11 @@ import com.datasqrl.ai.tool.Context; import java.io.IOException; import java.util.List; +import java.util.Set; import java.util.concurrent.CompletableFuture; import lombok.NonNull; +/** Interface for saving and retrieving messages against a GraphQL API */ public interface ChatPersistence { ChatPersistence NONE = @@ -23,11 +25,36 @@ public List getChatMessages( throws IOException { return List.of(); } + + @Override + public Set getMessageContextKeys() { + return Set.of(); + } }; + /** + * Saves the given message to the API + * + * @param message the generic message object that is serialized with Jackson + * @param context the sensitive context of the message. The context can contains user, session, + * and other information + * @return + */ public CompletableFuture saveChatMessage( @NonNull Object message, @NonNull Context context); + /** + * Retrieves messages from the API for a given context. + * + * @param context The context to retrieve messages in. Contains user and session information. + * @param limit The maximum number of messages to retrieve + * @param clazz The type of message to return + * @return + * @param + * @throws IOException + */ public List getChatMessages( @NonNull Context context, int limit, @NonNull Class clazz) throws IOException; + + Set getMessageContextKeys(); } diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/converter/APIFunctionFactory.java b/acorn-graphql/src/main/java/com/datasqrl/ai/converter/APIFunctionFactory.java index 2c82884..d18c06a 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/converter/APIFunctionFactory.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/converter/APIFunctionFactory.java @@ -4,6 +4,7 @@ import com.datasqrl.ai.tool.APIFunction; import com.datasqrl.ai.tool.FunctionDefinition; +/** Factory for {@link APIFunction} given a {@link FunctionDefinition} and {@link APIQuery} */ @FunctionalInterface public interface APIFunctionFactory { diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/converter/GraphQLSchemaConverter.java b/acorn-graphql/src/main/java/com/datasqrl/ai/converter/GraphQLSchemaConverter.java index 35a0b55..7a49ac0 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/converter/GraphQLSchemaConverter.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/converter/GraphQLSchemaConverter.java @@ -91,6 +91,15 @@ private static GraphQLSchema getSchema(String schemaString) { SchemaPrinter schemaPrinter = new SchemaPrinter(SchemaPrinter.Options.defaultOptions().descriptionsAsHashComments(true)); + /** + * Converts all operations defined within a given GraphQL operation definition string to an + * equivalent list of API Functions. + * + * @param operationDefinition a string defining GraphQL operations + * @return a list of API Functions equivalent to the provided GraphQL operations + * @throws IllegalArgumentException if operation definition contains no definitions or if an + * unexpected definition type is provided + */ public List convertOperations(String operationDefinition) { Parser parser = new Parser(); Document document = parser.parseDocument(operationDefinition); @@ -160,6 +169,12 @@ private static String comments2String(List comments) { return comments.stream().map(Comment::getContent).collect(Collectors.joining(" ")); } + /** + * Converts a given GraphQL operation definition into a FunctionDefinition. + * + * @param node the OperationDefinition to be converted + * @return a FunctionDefinition that corresponds to the provided OperationDefinition + */ public FunctionDefinition convertOperationDefinition(OperationDefinition node) { Operation op = node.getOperation(); ErrorHandling.checkArgument( @@ -194,6 +209,17 @@ public FunctionDefinition convertOperationDefinition(OperationDefinition node) { private record OperationField(Operation op, GraphQLFieldDefinition fieldDefinition) {} + /** + * Converts the whole GraphQL schema into a list of {@link APIFunction} instances. + * + *

This method will take the schema associated with this converter instance and convert every + * query and mutation in the schema into an equivalent {@link APIFunction}. The {@link + * APIFunction} instances are the ones that can be used by other parts of the system, acting as an + * equivalent representation of the original GraphQL operations. + * + * @return List of {@link APIFunction} instances corresponding to all the queries and mutations in + * the GraphQL schema. + */ public List convertSchema() { List functions = new ArrayList<>(); @@ -247,7 +273,7 @@ private Argument convert(Type type) { return argument; } - public static List getExtendedScalars() { + private static List getExtendedScalars() { List scalars = new ArrayList<>(); Field[] fields = ExtendedScalars.class.getFields(); @@ -279,12 +305,6 @@ public Context nested(String fieldName, GraphQLObjectType type, int additionalAr } } - private static String path2String(List path) { - return "[" - + path.stream().map(GraphQLObjectType::getName).collect(Collectors.joining(",")) - + "]"; - } - private static FunctionDefinition initializeFunctionDefinition(String name, String description) { FunctionDefinition funcDef = new FunctionDefinition(); Parameters params = new Parameters(); @@ -297,7 +317,7 @@ private static FunctionDefinition initializeFunctionDefinition(String name, Stri return funcDef; } - public APIFunction convert(Operation operationType, GraphQLFieldDefinition fieldDef) { + private APIFunction convert(Operation operationType, GraphQLFieldDefinition fieldDef) { FunctionDefinition funcDef = initializeFunctionDefinition(fieldDef.getName(), fieldDef.getDescription()); Parameters params = funcDef.getParameters(); @@ -387,11 +407,16 @@ public boolean visit( int numArgs = 0; if (!fieldDef.getArguments().isEmpty()) { queryBody.append("("); - for (GraphQLArgument arg : fieldDef.getArguments()) { + for (Iterator args = fieldDef.getArguments().iterator(); args.hasNext(); ) { + GraphQLArgument arg = args.next(); + UnwrappedType unwrappedType = convertRequired(arg.getType()); if (unwrappedType.type() instanceof GraphQLInputObjectType inputType) { queryBody.append(arg.getName()).append(": { "); - for (GraphQLInputObjectField nestedField : inputType.getFieldDefinitions()) { + for (Iterator nestedFields = + inputType.getFieldDefinitions().iterator(); + nestedFields.hasNext(); ) { + GraphQLInputObjectField nestedField = nestedFields.next(); String argName = combineStrings(ctx.prefix(), nestedField.getName()); unwrappedType = convertRequired(nestedField.getType()); argName = @@ -400,7 +425,6 @@ public boolean visit( queryHeader, params, ctx, - numArgs, unwrappedType, argName, nestedField.getName(), @@ -408,6 +432,9 @@ public boolean visit( String typeString = printFieldType(nestedField); queryHeader.append(argName).append(": ").append(typeString); numArgs++; + if (nestedFields.hasNext()) { + queryBody.append(", "); + } } queryBody.append(" }"); } else { @@ -418,7 +445,6 @@ public boolean visit( queryHeader, params, ctx, - numArgs, unwrappedType, argName, arg.getName(), @@ -427,6 +453,10 @@ public boolean visit( queryHeader.append(argName).append(": ").append(typeString); numArgs++; } + + if (args.hasNext()) { + queryBody.append(", "); + } } queryBody.append(")"); } @@ -459,15 +489,12 @@ private String processField( StringBuilder queryHeader, Parameters params, Context ctx, - int numArgs, UnwrappedType unwrappedType, String argName, String originalName, String description) { Argument argDef = convert(unwrappedType.type()); argDef.setDescription(description); - if (numArgs > 0) queryBody.append(", "); - if (ctx.numArgs() + numArgs > 0) queryHeader.append(", "); if (unwrappedType.required()) params.getRequired().add(argName); params.getProperties().put(argName, argDef); argName = "$" + argName; diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/converter/GraphQLSchemaConverterConfig.java b/acorn-graphql/src/main/java/com/datasqrl/ai/converter/GraphQLSchemaConverterConfig.java index b01758d..0f7c487 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/converter/GraphQLSchemaConverterConfig.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/converter/GraphQLSchemaConverterConfig.java @@ -6,6 +6,7 @@ import lombok.Builder; import lombok.Value; +/** Configuration class for {@link GraphQLSchemaConverter}. */ @Value @Builder public class GraphQLSchemaConverterConfig { @@ -13,10 +14,19 @@ public class GraphQLSchemaConverterConfig { public static final GraphQLSchemaConverterConfig DEFAULT = GraphQLSchemaConverterConfig.builder().build(); + /** Filter for selecting which operations to convert */ @Builder.Default BiPredicate operationFilter = (op, name) -> true; + /** The maximum depth of conversion for operations that have nested types */ @Builder.Default int maxDepth = 3; + /** + * Returns an operations filter that filters out all operations which start with the given list of + * prefixes. + * + * @param prefixes + * @return + */ public static BiPredicate ignorePrefix(String... prefixes) { final String[] prefixesLower = Arrays.stream(prefixes).map(String::trim).map(String::toLowerCase).toArray(String[]::new); diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/converter/StandardAPIFunctionFactory.java b/acorn-graphql/src/main/java/com/datasqrl/ai/converter/StandardAPIFunctionFactory.java index 938d815..6c6d24f 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/converter/StandardAPIFunctionFactory.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/converter/StandardAPIFunctionFactory.java @@ -6,6 +6,18 @@ import com.datasqrl.ai.tool.FunctionDefinition; import java.util.Set; +/** + * This is the factory class for creating APIFunction instances. It implements the + * APIFunctionFactory interface. The class is implemented using Java Records feature, which is a + * final immutable class by default. Being a factory class, its main responsibility is to generate + * and return instances of APIFunction class. + * + * @param apiExecutor is a APIQueryExecutor type object which executes the APIQuery. + * @param contextKeys is a set of Strings which are considered as keys in the context of this + * APIFunctionFactory. + *

Note: Any change in the fields of this class or method definitions will affect the objects + * created by this factory. + */ public record StandardAPIFunctionFactory(APIQueryExecutor apiExecutor, Set contextKeys) implements APIFunctionFactory { diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/tool/APIFunction.java b/acorn-graphql/src/main/java/com/datasqrl/ai/tool/APIFunction.java index 113416f..8a52e5a 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/tool/APIFunction.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/tool/APIFunction.java @@ -14,6 +14,11 @@ import lombok.NonNull; import lombok.Value; +/** + * Represents an API function or tool that. It contains the {@link FunctionDefinition} that is + * passed to the LLM as a tool and the {@link APIQuery} that is executed via the {@link + * APIQueryExecutor} to invoke this function/tool. + */ @Value public class APIFunction { @@ -53,6 +58,12 @@ public String getName() { return function.getName(); } + /** + * Removes the context keys from the {@link FunctionDefinition} to be passed to the LLM as + * tooling. + * + * @return LLM tool + */ @JsonIgnore public FunctionDefinition getModelFunction() { Predicate fieldFilter = getFieldFilter(contextKeys); @@ -79,6 +90,12 @@ private static Predicate getFieldFilter(Set fieldList) { return field -> !contextFilter.contains(field.toLowerCase()); } + /** + * Validate the arguments for this function/tool. + * + * @param arguments + * @return + */ public ValidationResult validate(JsonNode arguments) { return apiExecutor.validate(getModelFunction(), arguments); } diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/tool/FunctionUtil.java b/acorn-graphql/src/main/java/com/datasqrl/ai/tool/FunctionUtil.java index 7bc1f5e..aa9d7dc 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/tool/FunctionUtil.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/tool/FunctionUtil.java @@ -17,6 +17,15 @@ public static String toJsonString(List tools) throws IOException { return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(mapper.valueToTree(tools)); } + /** + * Adds/overwrites the context fields on the message with the provided context. + * + * @param arguments + * @param contextKeys + * @param context + * @param mapper + * @return + */ public static JsonNode addOrOverrideContext( JsonNode arguments, @NonNull Set contextKeys, diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/tool/ValidationResult.java b/acorn-graphql/src/main/java/com/datasqrl/ai/tool/ValidationResult.java index 9a102fb..1402cb2 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/tool/ValidationResult.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/tool/ValidationResult.java @@ -2,6 +2,12 @@ import lombok.NonNull; +/** + * The result of a function argument evaluation. + * + * @param errorType + * @param errorMessage + */ public record ValidationResult(@NonNull ErrorType errorType, String errorMessage) { public static final ValidationResult VALID = new ValidationResult(ErrorType.NONE, null); diff --git a/acorn-graphql/src/main/java/com/datasqrl/ai/util/ErrorHandling.java b/acorn-graphql/src/main/java/com/datasqrl/ai/util/ErrorHandling.java index b45a407..b75b335 100644 --- a/acorn-graphql/src/main/java/com/datasqrl/ai/util/ErrorHandling.java +++ b/acorn-graphql/src/main/java/com/datasqrl/ai/util/ErrorHandling.java @@ -1,5 +1,6 @@ package com.datasqrl.ai.util; +/** Utility methods for error handling */ public class ErrorHandling { public static void checkArgument(boolean condition, String message, Object... args) { diff --git a/acorn-graphql/src/test/java/com/datasqrl/ai/TestUtil.java b/acorn-graphql/src/test/java/com/datasqrl/ai/TestUtil.java index 5881a21..9c3cda2 100644 --- a/acorn-graphql/src/test/java/com/datasqrl/ai/TestUtil.java +++ b/acorn-graphql/src/test/java/com/datasqrl/ai/TestUtil.java @@ -1,7 +1,7 @@ package com.datasqrl.ai; import static java.nio.file.StandardOpenOption.CREATE; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.fail; import com.datasqrl.ai.util.ErrorHandling; @@ -28,8 +28,7 @@ public static String getResourcesFileAsString(String path) { @SneakyThrows public static void snapshotTest(String result, Path pathToExpected) { if (Files.isRegularFile(pathToExpected)) { - String expected = Files.readString(pathToExpected, StandardCharsets.UTF_8); - assertEquals(expected, result); + assertThat(pathToExpected).hasContent(result); } else { Files.writeString(pathToExpected, result, StandardCharsets.UTF_8, CREATE); fail("Created snapshot: " + pathToExpected.toAbsolutePath()); diff --git a/acorn-graphql/src/test/java/com/datasqrl/ai/converter/GraphQLSchemaConverterTest.java b/acorn-graphql/src/test/java/com/datasqrl/ai/converter/GraphQLSchemaConverterTest.java index c7caa60..09f9dee 100644 --- a/acorn-graphql/src/test/java/com/datasqrl/ai/converter/GraphQLSchemaConverterTest.java +++ b/acorn-graphql/src/test/java/com/datasqrl/ai/converter/GraphQLSchemaConverterTest.java @@ -2,6 +2,8 @@ import static com.datasqrl.ai.converter.GraphQLSchemaConverterConfig.ignorePrefix; import static graphql.Assert.assertFalse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -10,6 +12,8 @@ import com.datasqrl.ai.api.MockAPIExecutor; import com.datasqrl.ai.tool.APIFunction; import com.datasqrl.ai.tool.FunctionUtil; +import graphql.parser.Parser; +import graphql.schema.*; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -106,8 +110,47 @@ public static String convertToJsonDefault(List functions) { } public static void snapshot(List functions, String testName) { + for (APIFunction apiFunction : functions) { + // make sure ALL queries have a good syntax + var query = apiFunction.getApiQuery().query(); + assertDoesNotThrow( + () -> { + Parser.parse(query); + }); + } TestUtil.snapshotTest( convertToJsonDefault(functions), Path.of("src", "test", "resources", "snapshot", testName + ".json")); } + + @Test + void givenComplexFieldDefinition_whenVisiting_thenGenerateValidQuery() { + GraphQLSchemaConverter converter = + new GraphQLSchemaConverter( + TestUtil.getResourcesFileAsString("graphql/rick_morty-schema.graphqls"), + GraphQLSchemaConverterConfig.builder() + .operationFilter(ignorePrefix("internal")) + .build(), + new StandardAPIFunctionFactory(apiExecutor, Set.of())); + + List functions = converter.convertSchema(); + assertEquals(9, functions.size()); + // Test context key handling + APIFunction episodes = + functions.stream() + .filter(f -> f.getFunction().getName().equalsIgnoreCase("episodes")) + .findFirst() + .get(); + assertThat(episodes.getFunction().getParameters().getProperties()) + .containsKeys("name", "episode", "page"); + + var query = episodes.getApiQuery().query(); + assertDoesNotThrow( + () -> { + Parser.parse(query); + }); + assertFalse( + episodes.getModelFunction().getParameters().getProperties().containsKey("customerid")); + snapshot(functions, "rick-morty"); + } } diff --git a/acorn-graphql/src/test/resources/graphql/rick_morty-schema.graphqls b/acorn-graphql/src/test/resources/graphql/rick_morty-schema.graphqls new file mode 100644 index 0000000..1d7da8c --- /dev/null +++ b/acorn-graphql/src/test/resources/graphql/rick_morty-schema.graphqls @@ -0,0 +1,224 @@ + type Query { + """ + Get a specific character by ID + """ + character(id: ID!): Character + + """ + Get the list of all characters + """ + characters(page: Int, filter: FilterCharacter): Characters + + """ + Get a list of characters selected by ids + """ + charactersByIds(ids: [ID!]!): [Character] + + """ + Get a specific locations by ID + """ + location(id: ID!): Location + + """ + Get the list of all locations + """ + locations(page: Int, filter: FilterLocation): Locations + + """ + Get a list of locations selected by ids + """ + locationsByIds(ids: [ID!]!): [Location] + + """ + Get a specific episode by ID + """ + episode(id: ID!): Episode + + """ + Get the list of all episodes + """ + episodes(page: Int, filter: FilterEpisode): Episodes + + """ + Get a list of episodes selected by ids + """ + episodesByIds(ids: [ID!]!): [Episode] + } + + type Characters { + info: Info + results: [Character] + } + + type Locations { + info: Info + results: [Location] + } + + type Episodes { + info: Info + results: [Episode] + } + + type Character { + """ + The id of the character. + """ + id: ID + + """ + The name of the character. + """ + name: String + + """ + The status of the character ('Alive', 'Dead' or 'unknown'). + """ + status: String + + """ + The species of the character. + """ + species: String + + """ + The type or subspecies of the character. + """ + type: String + + """ + The gender of the character ('Female', 'Male', 'Genderless' or 'unknown'). + """ + gender: String + + """ + The character's origin location + """ + origin: Location + + """ + The character's last known location + """ + location: Location + + """ + Link to the character's image. + All images are 300x300px and most are medium shots or portraits since they are intended to be used as avatars. + """ + image: String + + """ + Episodes in which this character appeared. + """ + episode: [Episode]! + + """ + Time at which the character was created in the database. + """ + created: String + } + + type Location { + """ + The id of the location. + """ + id: ID + + """ + The name of the location. + """ + name: String + + """ + The type of the location. + """ + type: String + + """ + The dimension in which the location is located. + """ + dimension: String + + """ + List of characters who have been last seen in the location. + """ + residents: [Character]! + + """ + Time at which the location was created in the database. + """ + created: String + } + + type Episode { + """ + The id of the episode. + """ + id: ID + + """ + The name of the episode. + """ + name: String + + """ + The air date of the episode. + """ + air_date: String + + """ + The code of the episode. + """ + episode: String + + """ + List of characters who have been seen in the episode. + """ + characters: [Character]! + + """ + Time at which the episode was created in the database. + """ + created: String + } + + type Info { + """ + The length of the response. + """ + count: Int + + """ + The amount of pages. + """ + pages: Int + + """ + Number of the next page (if it exists) + """ + next: Int + + """ + Number of the previous page (if it exists) + """ + prev: Int + } + + input FilterCharacter { + name: String + status: String + species: String + type: String + gender: String + } + + input FilterLocation { + name: String + type: String + dimension: String + } + + input FilterEpisode { + name: String + episode: String + } diff --git a/acorn-graphql/src/test/resources/snapshot/creditcard-rewards.json b/acorn-graphql/src/test/resources/snapshot/creditcard-rewards.json index 649045a..f5b43a5 100644 --- a/acorn-graphql/src/test/resources/snapshot/creditcard-rewards.json +++ b/acorn-graphql/src/test/resources/snapshot/creditcard-rewards.json @@ -23,7 +23,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query Rewards($customerid: Int!, $fromTime: DateTime!, $toTime: DateTime!) {\nRewards(customerid: $customerid, fromTime: $fromTime, toTime: $toTime) {\ntransactionId\ncustomerid\ncardNo\ncardType\ntime\namount\nreward\nmerchantName\n}\n\n}" + "query" : "query Rewards($customerid: Int!$fromTime: DateTime!$toTime: DateTime!) {\nRewards(customerid: $customerid, fromTime: $fromTime, toTime: $toTime) {\ntransactionId\ncustomerid\ncardNo\ncardType\ntime\namount\nreward\nmerchantName\n}\n\n}" } }, { "function" : { @@ -50,7 +50,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query RewardsByWeek($customerid: Int!, $limit: Int = 12, $offset: Int = 0) {\nRewardsByWeek(customerid: $customerid, limit: $limit, offset: $offset) {\ncustomerid\ntimeWeek\ntotal_reward\n}\n\n}" + "query" : "query RewardsByWeek($customerid: Int!$limit: Int = 12$offset: Int = 0) {\nRewardsByWeek(customerid: $customerid, limit: $limit, offset: $offset) {\ncustomerid\ntimeWeek\ntotal_reward\n}\n\n}" } }, { "function" : { @@ -100,7 +100,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query PotentialRewards($customerid: Int!, $cardType: String!, $fromTime: DateTime!, $toTime: DateTime!) {\nPotentialRewards(customerid: $customerid, cardType: $cardType, fromTime: $fromTime, toTime: $toTime) {\ntransactionId\ncustomerid\nrewardCardType\ntime\namount\nreward\nmerchantName\n}\n\n}" + "query" : "query PotentialRewards($customerid: Int!$cardType: String!$fromTime: DateTime!$toTime: DateTime!) {\nPotentialRewards(customerid: $customerid, cardType: $cardType, fromTime: $fromTime, toTime: $toTime) {\ntransactionId\ncustomerid\nrewardCardType\ntime\namount\nreward\nmerchantName\n}\n\n}" } }, { "function" : { @@ -131,7 +131,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query PotentialRewardsByWeek($customerid: Int!, $cardType: String!, $limit: Int = 12, $offset: Int = 0) {\nPotentialRewardsByWeek(customerid: $customerid, cardType: $cardType, limit: $limit, offset: $offset) {\ncustomerid\ncardType\ntimeWeek\ntotal_reward\n}\n\n}" + "query" : "query PotentialRewardsByWeek($customerid: Int!$cardType: String!$limit: Int = 12$offset: Int = 0) {\nPotentialRewardsByWeek(customerid: $customerid, cardType: $cardType, limit: $limit, offset: $offset) {\ncustomerid\ncardType\ntimeWeek\ntotal_reward\n}\n\n}" } }, { "function" : { diff --git a/acorn-graphql/src/test/resources/snapshot/law_enforcement.json b/acorn-graphql/src/test/resources/snapshot/law_enforcement.json index e9dc4b7..5ec9133 100644 --- a/acorn-graphql/src/test/resources/snapshot/law_enforcement.json +++ b/acorn-graphql/src/test/resources/snapshot/law_enforcement.json @@ -27,7 +27,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query BoloDetails($make: String!, $model: String, $limit: Int = 10, $offset: Int = 0) {\nBoloDetails(make: $make, model: $model, limit: $limit, offset: $offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\n\n}" + "query" : "query BoloDetails($make: String!$model: String$limit: Int = 10$offset: Int = 0) {\nBoloDetails(make: $make, model: $model, limit: $limit, offset: $offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\n\n}" } }, { "function" : { @@ -84,7 +84,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query Driver($license_number: String!, $limit: Int = 10, $offset: Int = 0, $bolos_limit: Int = 10, $bolos_offset: Int = 0, $vehicles_limit: Int = 10, $vehicles_offset: Int = 0, $vehicles_bolos_limit: Int = 10, $vehicles_bolos_offset: Int = 0, $vehicles_tracking_limit: Int = 10, $vehicles_tracking_offset: Int = 0, $warrants_limit: Int = 10, $warrants_offset: Int = 0) {\nDriver(license_number: $license_number, limit: $limit, offset: $offset) {\ndriver_id\nfirst_name\nlast_name\nlicense_number\nlicense_state\ndate_of_birth\nlicense_status\nlicense_expiry_date\nlast_updated\nbolos(limit: $bolos_limit, offset: $bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\nvehicles(limit: $vehicles_limit, offset: $vehicles_offset) {\nvehicle_id\nregistration_number\nregistration_state\nregistration_expiry\nmake\nmodel\nyear\nowner_driver_id\nlast_updated\nbolos(limit: $vehicles_bolos_limit, offset: $vehicles_bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\ntracking(limit: $vehicles_tracking_limit, offset: $vehicles_tracking_offset) {\nlatitude\nlongitude\nevent_time\n}\n}\nwarrants(limit: $warrants_limit, offset: $warrants_offset) {\nwarrant_id\nperson_id\nwarrant_status\ncrime_description\nstate_of_issuance\nissue_date\nlast_updated\n}\n}\n\n}" + "query" : "query Driver($license_number: String!$limit: Int = 10$offset: Int = 0$bolos_limit: Int = 10$bolos_offset: Int = 0$vehicles_limit: Int = 10$vehicles_offset: Int = 0$vehicles_bolos_limit: Int = 10$vehicles_bolos_offset: Int = 0$vehicles_tracking_limit: Int = 10$vehicles_tracking_offset: Int = 0$warrants_limit: Int = 10$warrants_offset: Int = 0) {\nDriver(license_number: $license_number, limit: $limit, offset: $offset) {\ndriver_id\nfirst_name\nlast_name\nlicense_number\nlicense_state\ndate_of_birth\nlicense_status\nlicense_expiry_date\nlast_updated\nbolos(limit: $bolos_limit, offset: $bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\nvehicles(limit: $vehicles_limit, offset: $vehicles_offset) {\nvehicle_id\nregistration_number\nregistration_state\nregistration_expiry\nmake\nmodel\nyear\nowner_driver_id\nlast_updated\nbolos(limit: $vehicles_bolos_limit, offset: $vehicles_bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\ntracking(limit: $vehicles_tracking_limit, offset: $vehicles_tracking_offset) {\nlatitude\nlongitude\nevent_time\n}\n}\nwarrants(limit: $warrants_limit, offset: $warrants_offset) {\nwarrant_id\nperson_id\nwarrant_status\ncrime_description\nstate_of_issuance\nissue_date\nlast_updated\n}\n}\n\n}" } }, { "function" : { @@ -123,7 +123,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query Vehicle($registration_number: String!, $limit: Int = 10, $offset: Int = 0, $bolos_limit: Int = 10, $bolos_offset: Int = 0, $tracking_limit: Int = 10, $tracking_offset: Int = 0) {\nVehicle(registration_number: $registration_number, limit: $limit, offset: $offset) {\nvehicle_id\nregistration_number\nregistration_state\nregistration_expiry\nmake\nmodel\nyear\nowner_driver_id\nlast_updated\nbolos(limit: $bolos_limit, offset: $bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\ntracking(limit: $tracking_limit, offset: $tracking_offset) {\nlatitude\nlongitude\nevent_time\n}\n}\n\n}" + "query" : "query Vehicle($registration_number: String!$limit: Int = 10$offset: Int = 0$bolos_limit: Int = 10$bolos_offset: Int = 0$tracking_limit: Int = 10$tracking_offset: Int = 0) {\nVehicle(registration_number: $registration_number, limit: $limit, offset: $offset) {\nvehicle_id\nregistration_number\nregistration_state\nregistration_expiry\nmake\nmodel\nyear\nowner_driver_id\nlast_updated\nbolos(limit: $bolos_limit, offset: $bolos_offset) {\nbolo_id\nvehicle_id\nissue_date\nstatus\nlast_updated\nmake\nmodel\nyear\nregistration_state\nregistration_number\nlicense_state\ndriver_id\n}\ntracking(limit: $tracking_limit, offset: $tracking_offset) {\nlatitude\nlongitude\nevent_time\n}\n}\n\n}" } }, { "function" : { @@ -148,7 +148,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query WarrantsByCrime($crime: String, $limit: Int = 100, $offset: Int = 0) {\nWarrantsByCrime(crime: $crime, limit: $limit, offset: $offset) {\ncrime\nnum_warrants\n}\n\n}" + "query" : "query WarrantsByCrime($crime: String$limit: Int = 100$offset: Int = 0) {\nWarrantsByCrime(crime: $crime, limit: $limit, offset: $offset) {\ncrime\nnum_warrants\n}\n\n}" } }, { "function" : { @@ -173,7 +173,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query WarrantsByState($status: String, $limit: Int = 100, $offset: Int = 0) {\nWarrantsByState(status: $status, limit: $limit, offset: $offset) {\nstate\nstatus\nnum_warrants\n}\n\n}" + "query" : "query WarrantsByState($status: String$limit: Int = 100$offset: Int = 0) {\nWarrantsByState(status: $status, limit: $limit, offset: $offset) {\nstate\nstatus\nnum_warrants\n}\n\n}" } }, { "function" : { @@ -200,7 +200,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query BolosByWeekState($state: String, $limit: Int = 100, $offset: Int = 0) {\nBolosByWeekState(state: $state, limit: $limit, offset: $offset) {\nweek\nstate\nnum_bolos\n}\n\n}" + "query" : "query BolosByWeekState($state: String$limit: Int = 100$offset: Int = 0) {\nBolosByWeekState(state: $state, limit: $limit, offset: $offset) {\nweek\nstate\nnum_bolos\n}\n\n}" } }, { "function" : { @@ -223,6 +223,6 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "mutation Tracking($plate: String!, $latitude: Float!, $longitude: Float!) {\nTracking(encounter: { plate: $plate, latitude: $latitude, longitude: $longitude }) {\n_uuid\nplate\n}\n\n}" + "query" : "mutation Tracking($plate: String!$latitude: Float!$longitude: Float!) {\nTracking(encounter: { plate: $plate, latitude: $latitude, longitude: $longitude }) {\n_uuid\nplate\n}\n\n}" } } ] \ No newline at end of file diff --git a/acorn-graphql/src/test/resources/snapshot/nutshop.json b/acorn-graphql/src/test/resources/snapshot/nutshop.json index 441ef9e..4c33c7a 100644 --- a/acorn-graphql/src/test/resources/snapshot/nutshop.json +++ b/acorn-graphql/src/test/resources/snapshot/nutshop.json @@ -23,7 +23,7 @@ }, "contextKeys" : [ "customerid" ], "apiQuery" : { - "query" : "query SpendingByWeek($customerid: Int!, $limit: Int = 10, $offset: Int = 0) {\nSpendingByWeek(customerid: $customerid, limit: $limit, offset: $offset) {\nweek\ntotal_spend\ntotal_savings\n}\n\n}" + "query" : "query SpendingByWeek($customerid: Int!$limit: Int = 10$offset: Int = 0) {\nSpendingByWeek(customerid: $customerid, limit: $limit, offset: $offset) {\nweek\ntotal_spend\ntotal_savings\n}\n\n}" } }, { "function" : { @@ -56,7 +56,7 @@ }, "contextKeys" : [ "customerid" ], "apiQuery" : { - "query" : "query Products($id: Int, $limit: Int = 10, $offset: Int = 0, $orders_limit: Int = 10, $orders_items_limit: Int = 10) {\nProducts(id: $id, limit: $limit, offset: $offset) {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\norders(limit: $orders_limit) {\nid\ncustomerid\ntimestamp\nitems(limit: $orders_items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\n}\ntotal {\nprice\ndiscount\n}\n}\n}\n\n}" + "query" : "query Products($id: Int$limit: Int = 10$offset: Int = 0$orders_limit: Int = 10$orders_items_limit: Int = 10) {\nProducts(id: $id, limit: $limit, offset: $offset) {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\norders(limit: $orders_limit) {\nid\ncustomerid\ntimestamp\nitems(limit: $orders_items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\n}\ntotal {\nprice\ndiscount\n}\n}\n}\n\n}" } }, { "function" : { @@ -86,7 +86,7 @@ }, "contextKeys" : [ "customerid" ], "apiQuery" : { - "query" : "query Orders($customerid: Int!, $limit: Int = 10, $offset: Int = 0, $items_limit: Int = 10) {\nOrders(customerid: $customerid, limit: $limit, offset: $offset) {\nid\ncustomerid\ntimestamp\nitems(limit: $items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\n}\n}\ntotal {\nprice\ndiscount\n}\n}\n\n}" + "query" : "query Orders($customerid: Int!$limit: Int = 10$offset: Int = 0$items_limit: Int = 10) {\nOrders(customerid: $customerid, limit: $limit, offset: $offset) {\nid\ncustomerid\ntimestamp\nitems(limit: $items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\n}\n}\ntotal {\nprice\ndiscount\n}\n}\n\n}" } }, { "function" : { @@ -116,7 +116,7 @@ }, "contextKeys" : [ "customerid" ], "apiQuery" : { - "query" : "query OrdersByTimeRange($customerid: Int!, $fromTime: DateTime!, $toTime: DateTime!, $items_limit: Int = 10) {\nOrdersByTimeRange(customerid: $customerid, fromTime: $fromTime, toTime: $toTime) {\nid\ncustomerid\ntimestamp\nitems(limit: $items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\n}\n}\ntotal {\nprice\ndiscount\n}\n}\n\n}" + "query" : "query OrdersByTimeRange($customerid: Int!$fromTime: DateTime!$toTime: DateTime!$items_limit: Int = 10) {\nOrdersByTimeRange(customerid: $customerid, fromTime: $fromTime, toTime: $toTime) {\nid\ncustomerid\ntimestamp\nitems(limit: $items_limit) {\nquantity\nunit_price\ndiscount0\ntotal\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\n}\n}\ntotal {\nprice\ndiscount\n}\n}\n\n}" } }, { "function" : { @@ -146,6 +146,6 @@ }, "contextKeys" : [ "customerid" ], "apiQuery" : { - "query" : "query OrderAgain($customerid: Int!, $limit: Int = 10, $offset: Int = 0, $product_orders_limit: Int = 10) {\nOrderAgain(customerid: $customerid, limit: $limit, offset: $offset) {\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\norders(limit: $product_orders_limit) {\nid\ncustomerid\ntimestamp\n}\n}\nnum\nquantity\n}\n\n}" + "query" : "query OrderAgain($customerid: Int!$limit: Int = 10$offset: Int = 0$product_orders_limit: Int = 10) {\nOrderAgain(customerid: $customerid, limit: $limit, offset: $offset) {\nproduct {\nid\nname\nsizing\nweight_in_gram\ntype\ncategory\nusda_id\nupdated\norders(limit: $product_orders_limit) {\nid\ncustomerid\ntimestamp\n}\n}\nnum\nquantity\n}\n\n}" } } ] \ No newline at end of file diff --git a/acorn-graphql/src/test/resources/snapshot/rick-morty.json b/acorn-graphql/src/test/resources/snapshot/rick-morty.json new file mode 100644 index 0000000..f430968 --- /dev/null +++ b/acorn-graphql/src/test/resources/snapshot/rick-morty.json @@ -0,0 +1,202 @@ +[ { + "function" : { + "name" : "character", + "description" : "Get a specific character by ID", + "parameters" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + } + }, + "required" : [ "id" ] + } + }, + "contextKeys" : [ ], + "apiQuery" : { + "query" : "query character($id: ID!) {\ncharacter(id: $id) {\nid\nname\nstatus\nspecies\ntype\ngender\norigin {\nid\nname\ntype\ndimension\ncreated\n}\nlocation {\nid\nname\ntype\ndimension\ncreated\n}\nimage\nepisode {\nid\nname\nair_date\nepisode\ncreated\n}\ncreated\n}\n\n}" + } +}, { + "function" : { + "name" : "characters", + "description" : "Get the list of all characters", + "parameters" : { + "type" : "object", + "properties" : { + "gender" : { + "type" : "string" + }, + "species" : { + "type" : "string" + }, + "name" : { + "type" : "string" + }, + "page" : { + "type" : "integer" + }, + "type" : { + "type" : "string" + }, + "status" : { + "type" : "string" + } + }, + "required" : [ ] + } + }, + "contextKeys" : [ ], + "apiQuery" : { + "query" : "query characters($page: Int$name: String$status: String$species: String$type: String$gender: String) {\ncharacters(page: $page, filter: { name: $name, status: $status, species: $species, type: $type, gender: $gender }) {\ninfo {\ncount\npages\nnext\nprev\n}\nresults {\nid\nname\nstatus\nspecies\ntype\ngender\norigin {\nid\nname\ntype\ndimension\ncreated\n}\nlocation {\nid\nname\ntype\ndimension\ncreated\n}\nimage\nepisode {\nid\nname\nair_date\nepisode\ncreated\n}\ncreated\n}\n}\n\n}" + } +}, { + "function" : { + "name" : "charactersByIds", + "description" : "Get a list of characters selected by ids", + "parameters" : { + "type" : "object", + "properties" : { + "ids" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "required" : [ "ids" ] + } + }, + "contextKeys" : [ ], + "apiQuery" : { + "query" : "query charactersByIds($ids: [ID!]!) {\ncharactersByIds(ids: $ids) {\nid\nname\nstatus\nspecies\ntype\ngender\norigin {\nid\nname\ntype\ndimension\ncreated\n}\nlocation {\nid\nname\ntype\ndimension\ncreated\n}\nimage\nepisode {\nid\nname\nair_date\nepisode\ncreated\n}\ncreated\n}\n\n}" + } +}, { + "function" : { + "name" : "location", + "description" : "Get a specific locations by ID", + "parameters" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + } + }, + "required" : [ "id" ] + } + }, + "contextKeys" : [ ], + "apiQuery" : { + "query" : "query location($id: ID!) {\nlocation(id: $id) {\nid\nname\ntype\ndimension\nresidents {\nid\nname\nstatus\nspecies\ntype\ngender\nimage\nepisode {\nid\nname\nair_date\nepisode\ncreated\n}\ncreated\n}\ncreated\n}\n\n}" + } +}, { + "function" : { + "name" : "locations", + "description" : "Get the list of all locations", + "parameters" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "page" : { + "type" : "integer" + }, + "type" : { + "type" : "string" + }, + "dimension" : { + "type" : "string" + } + }, + "required" : [ ] + } + }, + "contextKeys" : [ ], + "apiQuery" : { + "query" : "query locations($page: Int$name: String$type: String$dimension: String) {\nlocations(page: $page, filter: { name: $name, type: $type, dimension: $dimension }) {\ninfo {\ncount\npages\nnext\nprev\n}\nresults {\nid\nname\ntype\ndimension\nresidents {\nid\nname\nstatus\nspecies\ntype\ngender\nimage\ncreated\n}\ncreated\n}\n}\n\n}" + } +}, { + "function" : { + "name" : "locationsByIds", + "description" : "Get a list of locations selected by ids", + "parameters" : { + "type" : "object", + "properties" : { + "ids" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "required" : [ "ids" ] + } + }, + "contextKeys" : [ ], + "apiQuery" : { + "query" : "query locationsByIds($ids: [ID!]!) {\nlocationsByIds(ids: $ids) {\nid\nname\ntype\ndimension\nresidents {\nid\nname\nstatus\nspecies\ntype\ngender\nimage\nepisode {\nid\nname\nair_date\nepisode\ncreated\n}\ncreated\n}\ncreated\n}\n\n}" + } +}, { + "function" : { + "name" : "episode", + "description" : "Get a specific episode by ID", + "parameters" : { + "type" : "object", + "properties" : { + "id" : { + "type" : "string" + } + }, + "required" : [ "id" ] + } + }, + "contextKeys" : [ ], + "apiQuery" : { + "query" : "query episode($id: ID!) {\nepisode(id: $id) {\nid\nname\nair_date\nepisode\ncharacters {\nid\nname\nstatus\nspecies\ntype\ngender\norigin {\nid\nname\ntype\ndimension\ncreated\n}\nlocation {\nid\nname\ntype\ndimension\ncreated\n}\nimage\ncreated\n}\ncreated\n}\n\n}" + } +}, { + "function" : { + "name" : "episodes", + "description" : "Get the list of all episodes", + "parameters" : { + "type" : "object", + "properties" : { + "name" : { + "type" : "string" + }, + "episode" : { + "type" : "string" + }, + "page" : { + "type" : "integer" + } + }, + "required" : [ ] + } + }, + "contextKeys" : [ ], + "apiQuery" : { + "query" : "query episodes($page: Int$name: String$episode: String) {\nepisodes(page: $page, filter: { name: $name, episode: $episode }) {\ninfo {\ncount\npages\nnext\nprev\n}\nresults {\nid\nname\nair_date\nepisode\ncharacters {\nid\nname\nstatus\nspecies\ntype\ngender\nimage\ncreated\n}\ncreated\n}\n}\n\n}" + } +}, { + "function" : { + "name" : "episodesByIds", + "description" : "Get a list of episodes selected by ids", + "parameters" : { + "type" : "object", + "properties" : { + "ids" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + }, + "required" : [ "ids" ] + } + }, + "contextKeys" : [ ], + "apiQuery" : { + "query" : "query episodesByIds($ids: [ID!]!) {\nepisodesByIds(ids: $ids) {\nid\nname\nair_date\nepisode\ncharacters {\nid\nname\nstatus\nspecies\ntype\ngender\norigin {\nid\nname\ntype\ndimension\ncreated\n}\nlocation {\nid\nname\ntype\ndimension\ncreated\n}\nimage\ncreated\n}\ncreated\n}\n\n}" + } +} ] \ No newline at end of file diff --git a/acorn-graphql/src/test/resources/snapshot/sensors.json b/acorn-graphql/src/test/resources/snapshot/sensors.json index 244ebb5..54ff581 100644 --- a/acorn-graphql/src/test/resources/snapshot/sensors.json +++ b/acorn-graphql/src/test/resources/snapshot/sensors.json @@ -22,7 +22,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query SensorReading($sensorid: Int!, $limit: Int = 10, $offset: Int = 0) {\nSensorReading(sensorid: $sensorid, limit: $limit, offset: $offset) {\nsensorid\ntemperature\nevent_time\n}\n\n}" + "query" : "query SensorReading($sensorid: Int!$limit: Int = 10$offset: Int = 0) {\nSensorReading(sensorid: $sensorid, limit: $limit, offset: $offset) {\nsensorid\ntemperature\nevent_time\n}\n\n}" } }, { "function" : { @@ -43,7 +43,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query ReadingsAboveTemp($temp: Float!, $limit: Int = 10) {\nReadingsAboveTemp(temp: $temp, limit: $limit) {\nsensorid\ntemperature\nevent_time\n}\n\n}" + "query" : "query ReadingsAboveTemp($temp: Float!$limit: Int = 10) {\nReadingsAboveTemp(temp: $temp, limit: $limit) {\nsensorid\ntemperature\nevent_time\n}\n\n}" } }, { "function" : { @@ -69,7 +69,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query SensorMaxTempLastMinute($sensorid: Int, $limit: Int = 10, $offset: Int = 0) {\nSensorMaxTempLastMinute(sensorid: $sensorid, limit: $limit, offset: $offset) {\nsensorid\nmaxTemp\nlast_updated\n}\n\n}" + "query" : "query SensorMaxTempLastMinute($sensorid: Int$limit: Int = 10$offset: Int = 0) {\nSensorMaxTempLastMinute(sensorid: $sensorid, limit: $limit, offset: $offset) {\nsensorid\nmaxTemp\nlast_updated\n}\n\n}" } }, { "function" : { @@ -95,7 +95,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "query SensorMaxTemp($sensorid: Int, $limit: Int = 10, $offset: Int = 0) {\nSensorMaxTemp(sensorid: $sensorid, limit: $limit, offset: $offset) {\nsensorid\nmaxTemp\nlast_updated\n}\n\n}" + "query" : "query SensorMaxTemp($sensorid: Int$limit: Int = 10$offset: Int = 0) {\nSensorMaxTemp(sensorid: $sensorid, limit: $limit, offset: $offset) {\nsensorid\nmaxTemp\nlast_updated\n}\n\n}" } }, { "function" : { @@ -115,7 +115,7 @@ }, "contextKeys" : [ ], "apiQuery" : { - "query" : "mutation AddReading($sensorid: Int!, $temperature: Float!) {\nAddReading(metric: { sensorid: $sensorid, temperature: $temperature }) {\nsensorid\ntemperature\nevent_time\n}\n\n}" + "query" : "mutation AddReading($sensorid: Int!$temperature: Float!) {\nAddReading(metric: { sensorid: $sensorid, temperature: $temperature }) {\nsensorid\ntemperature\nevent_time\n}\n\n}" } }, { "function" : { diff --git a/acorn-springai/README.md b/acorn-springai/README.md index 08cd523..2df10a4 100644 --- a/acorn-springai/README.md +++ b/acorn-springai/README.md @@ -1,8 +1,17 @@ # Acorn with Spring AI -This project is an example implementation of how to use Acorn within the Spring AI framework with -OpenAI as the model provider. The goal is to identify the right interface between acorn and spring-ai -so we can make the integration easy. +A Spring AI module that integrates Acorn with Spring AI for tooling and chat memory. +It provides: +- Converts GraphQL API into callback functions for LLM tooling +- Implements ChatMemory against GraphQL API + +## Example Spring AI Application using Acorn + +### Ricky and Morty Chatbot + +TODO + +### Credit Card Agent The implementation expects that the DataSQRL rewards credit card example is running for information retrieval and message persistence. diff --git a/acorn-springai/lombok.config b/acorn-springai/lombok.config new file mode 100644 index 0000000..2a2f058 --- /dev/null +++ b/acorn-springai/lombok.config @@ -0,0 +1,2 @@ +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Value diff --git a/acorn-springai/pom.xml b/acorn-springai/pom.xml index 0e1128a..8a7e994 100644 --- a/acorn-springai/pom.xml +++ b/acorn-springai/pom.xml @@ -10,24 +10,6 @@ acorn-springai Acorn - Spring AI Adapter - http://maven.apache.org - - 3.3.0 - 1.4.0 - - - - - - org.springframework.ai - spring-ai-bom - 1.0.0-SNAPSHOT - pom - import - - - - ${project.groupId} @@ -41,56 +23,10 @@ ${networknt.version} - - org.springframework.boot - spring-boot-starter-web - ${spring.version} - - - - org.springframework.boot - spring-boot-starter-actuator - ${spring.version} - - org.springframework.ai spring-ai-openai-spring-boot-starter - - - org.springframework.boot - spring-boot-starter-test - ${spring.version} - test - - - - - false - - spring-milestones - Spring Milestones - https://repo.spring.io/milestone - - - - false - - spring-snapshots - Spring Snapshots - https://repo.spring.io/snapshot - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - diff --git a/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornChatMemory.java b/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornChatMemory.java index d95c986..080863b 100644 --- a/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornChatMemory.java +++ b/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornChatMemory.java @@ -1,6 +1,7 @@ package com.datasqrl.ai.acorn; import com.datasqrl.ai.chat.APIChatPersistence; +import com.datasqrl.ai.chat.ChatPersistence; import com.datasqrl.ai.tool.Context; import com.datasqrl.ai.tool.ContextImpl; import com.datasqrl.ai.util.ErrorHandling; @@ -9,7 +10,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import lombok.Value; +import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.messages.AbstractMessage; @@ -18,14 +19,18 @@ import org.springframework.ai.chat.messages.MessageType; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; -@Value +/** Implements Spring AI's {@link ChatMemory} using Acorn's {@link APIChatPersistence}. */ +@Service +@RequiredArgsConstructor public class AcornChatMemory implements ChatMemory { - public static final String DEFAULT_CONTEXT_PREFIX = "chat_memory_conversation_"; + private final ChatPersistence chatPersistence; - APIChatPersistence chatPersistence; - String contextPrefix; + @Value("${acorn.chat.context.prefix:chat_memory_conversation_}") + private final String contextPrefix; @Override public void add(String conversationId, List messages) { @@ -61,6 +66,15 @@ private Map toMap(String conversationId) { return Map.of(AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId); } + /** + * Converts the given advisor context map to a Context object by first filtering it based on the + * prefix, then ensuring that all required keys are present in the context. The resulting Context + * object represents the essential context for our ChatMemory management. + * + * @param advisorContext The initial context map. + * @return A Context object built from the given map. + * @throws IllegalArgumentException if a required key is not found in the advisorContext. + */ private Context toContext(Map advisorContext) { Map filteredContext = new HashMap<>(); if (contextPrefix != null) { @@ -69,7 +83,7 @@ private Context toContext(Map advisorContext) { .forEach(entry -> filteredContext.put(entry.getKey(), entry.getValue())); } // Add required keys, make sure they exist - for (String requiredKey : chatPersistence.getGetMessageContextKeys()) { + for (String requiredKey : chatPersistence.getMessageContextKeys()) { Object value = advisorContext.get(requiredKey); ErrorHandling.checkArgument( value != null, "Advisor context does not contain required key: %s", requiredKey); diff --git a/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornChatMemoryAdvisor.java b/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornChatMemoryAdvisor.java index 3a62c6d..b5e98e3 100644 --- a/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornChatMemoryAdvisor.java +++ b/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornChatMemoryAdvisor.java @@ -14,6 +14,13 @@ import org.springframework.ai.chat.model.MessageAggregator; import reactor.core.publisher.Flux; +/** + * Implements a custom MessageChatMemoryAdvisor for {@link AcornChatMemory} because the default + * Spring AI implementation makes limiting assumptions about how the context can be passed in. + * + *

This class only meaningfully changes the {@link #observeAfter(AdvisedResponse)} and {@link + * #before(AdvisedRequest)} methods to get the full advise context and pass it through. + */ public class AcornChatMemoryAdvisor extends AbstractChatMemoryAdvisor { public AcornChatMemoryAdvisor(AcornChatMemory chatMemory) { diff --git a/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornConfiguration.java b/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornConfiguration.java new file mode 100644 index 0000000..47f28b0 --- /dev/null +++ b/acorn-springai/src/main/java/com/datasqrl/ai/acorn/AcornConfiguration.java @@ -0,0 +1,32 @@ +package com.datasqrl.ai.acorn; + +import com.datasqrl.ai.converter.GraphQLSchemaConverter; +import java.util.Optional; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AcornConfiguration { + + @Value("${config.backend-url}") + private String backendUrl; + + @Bean + ChatClient chatClient( + ChatModel model, AcornChatMemory chatMemory, GraphQLSchemaConverter graphQLSchemaConverter) { + GraphQLTools toolConverter = new GraphQLTools(graphQLSchemaConverter); + // Builds a chat client using Acorn's GraphQL API as tools and chat persistence + return ChatClient.builder(model) + .defaultAdvisors(new AcornChatMemoryAdvisor(chatMemory, 10)) + .defaultTools(toolConverter.getSchemaTools()) + .build(); + } + + @Bean + SpringGraphQLExecutor getAPIExecutor() { + return new SpringGraphQLExecutor(backendUrl, Optional.empty()); + } +} diff --git a/acorn-springai/src/main/java/com/datasqrl/ai/acorn/EnableAcorn.java b/acorn-springai/src/main/java/com/datasqrl/ai/acorn/EnableAcorn.java new file mode 100644 index 0000000..17e7baa --- /dev/null +++ b/acorn-springai/src/main/java/com/datasqrl/ai/acorn/EnableAcorn.java @@ -0,0 +1,12 @@ +package com.datasqrl.ai.acorn; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.context.annotation.Import; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import({AcornChatMemory.class, AcornConfiguration.class}) +public @interface EnableAcorn {} diff --git a/acorn-springai/src/main/java/com/datasqrl/ai/acorn/GraphQLTools.java b/acorn-springai/src/main/java/com/datasqrl/ai/acorn/GraphQLTools.java index 4f154a7..1a58b2a 100644 --- a/acorn-springai/src/main/java/com/datasqrl/ai/acorn/GraphQLTools.java +++ b/acorn-springai/src/main/java/com/datasqrl/ai/acorn/GraphQLTools.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; @@ -17,7 +18,11 @@ import lombok.Value; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.model.function.FunctionCallback; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.web.client.HttpClientErrorException.BadRequest; +/** Creates Spring AI's {@link FunctionCallback} from Acorn's {@link APIFunction} */ @Value public class GraphQLTools { @@ -27,59 +32,60 @@ public GraphQLTools(GraphQLSchemaConverter graphQLConverter) { this.graphQLConverter = graphQLConverter; } - public FunctionCallback[] getSchemaTools() { + public ToolCallback[] getSchemaTools() { return from(graphQLConverter.convertSchema()); } - public FunctionCallback[] getOperationTools(String operationDefinitions) { + public ToolCallback[] getOperationTools(String operationDefinitions) { return from(graphQLConverter.convertOperations(operationDefinitions)); } - public static FunctionCallback[] from(Collection... functions) { + public static ToolCallback[] from(List functions) { + return functions.stream().map(GraphQLTools::from).toArray(ToolCallback[]::new); + } + + public static ToolCallback[] from(Collection... functions) { return Arrays.stream(functions) .flatMap(Collection::stream) .map(GraphQLTools::from) - .toArray(FunctionCallback[]::new); + .toArray(ToolCallback[]::new); } - public static FunctionCallback[] from(APIFunction... functions) { - return Arrays.stream(functions).map(GraphQLTools::from).toArray(FunctionCallback[]::new); + public static ToolCallback[] from(APIFunction... functions) { + return Arrays.stream(functions).map(GraphQLTools::from).toArray(ToolCallback[]::new); } - public static FunctionCallback from(APIFunction function) { + public static ToolCallback from(APIFunction function) { FunctionDefinition funcDef = function.getModelFunction(); String inputSchema = toJsonString(funcDef.getParameters(), function.getApiExecutor().getObjectMapper()); - return new FunctionCallback() { - @Override - public String getName() { - return funcDef.getName(); - } - - @Override - public String getDescription() { - return funcDef.getDescription(); - } - - @Override - public String getInputTypeSchema() { - return inputSchema; - } + return new ToolCallback() { @Override public String call(String functionInput) { - return call(functionInput, null); + return call(functionInput, new ToolContext(Map.of())); } @Override public String call(String functionInput, ToolContext toolContext) { - Context ctx = toolContext == null ? Context.EMPTY : new ToolContextWrapper(toolContext); + Context ctx = new ToolContextWrapper(toolContext); try { return function.validateAndExecute(functionInput, ctx); + } catch (BadRequest e) { + return "Invalid graphql Query, got the following error: " + e.getMessage(); } catch (IOException e) { // This must be an operational exception, hence escalate throw new RuntimeException(e); } } + + @Override + public ToolDefinition getToolDefinition() { + return ToolDefinition.builder() + .name(funcDef.getName()) + .description(funcDef.getDescription()) + .inputSchema(inputSchema) + .build(); + } }; } diff --git a/acorn-springai/src/main/java/com/datasqrl/ai/spring/Config.java b/acorn-springai/src/main/java/com/datasqrl/ai/spring/Config.java deleted file mode 100644 index 92914b9..0000000 --- a/acorn-springai/src/main/java/com/datasqrl/ai/spring/Config.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.datasqrl.ai.spring; - -import static com.datasqrl.ai.converter.GraphQLSchemaConverterConfig.ignorePrefix; - -import com.datasqrl.ai.acorn.AcornChatMemory; -import com.datasqrl.ai.acorn.AcornChatMemoryAdvisor; -import com.datasqrl.ai.acorn.AcornSpringAIUtils; -import com.datasqrl.ai.acorn.GraphQLTools; -import com.datasqrl.ai.acorn.SpringGraphQLExecutor; -import com.datasqrl.ai.api.GraphQLQuery; -import com.datasqrl.ai.chat.APIChatPersistence; -import com.datasqrl.ai.converter.GraphQLSchemaConverter; -import com.datasqrl.ai.converter.GraphQLSchemaConverterConfig; -import com.datasqrl.ai.converter.StandardAPIFunctionFactory; -import java.util.Optional; -import java.util.Set; -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.io.Resource; -import org.springframework.core.io.ResourceLoader; - -@Configuration -class Config { - - public static final String USERID_KEY = "chat_memory_conversation_userid"; - - private final ResourceLoader resourceLoader; - private final ServerProperties properties; - - public Config(ResourceLoader resourceLoader, ServerProperties properties) { - this.resourceLoader = resourceLoader; - this.properties = properties; - } - - private Resource getResourceFromClasspath(String path) { - return resourceLoader.getResource("classpath:" + path); - } - - private String loadResourceFileAsString(String path) { - return AcornSpringAIUtils.loadResourceAsString(getResourceFromClasspath(path)); - } - - @Bean - ChatClient chatClient(ChatClient.Builder builder) { - GraphQLTools toolConverter = - new GraphQLTools( - new GraphQLSchemaConverter( - loadResourceFileAsString("tools/schema.graphqls"), - GraphQLSchemaConverterConfig.builder() - .operationFilter(ignorePrefix("Internal")) - .build(), - new StandardAPIFunctionFactory(getAPIExecutor(), Set.of("customerid")))); - - return builder - .defaultAdvisors(new AcornChatMemoryAdvisor(getMemory(), 10)) - .defaultFunctions(toolConverter.getSchemaTools()) - .build(); - } - - private SpringGraphQLExecutor getAPIExecutor() { - return new SpringGraphQLExecutor(properties.getBackendUrl(), Optional.empty()); - } - - private AcornChatMemory getMemory() { - APIChatPersistence chatPersistence = - new APIChatPersistence( - getAPIExecutor(), - new GraphQLQuery(loadResourceFileAsString("memory/saveMessage.graphql")), - new GraphQLQuery(loadResourceFileAsString("memory/getMessage.graphql")), - Set.of(USERID_KEY)); - return new AcornChatMemory(chatPersistence, AcornChatMemory.DEFAULT_CONTEXT_PREFIX); - } -} diff --git a/acorn-springai/src/main/java/com/datasqrl/ai/spring/ServerProperties.java b/acorn-springai/src/main/java/com/datasqrl/ai/spring/ServerProperties.java deleted file mode 100644 index 322e6b2..0000000 --- a/acorn-springai/src/main/java/com/datasqrl/ai/spring/ServerProperties.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.datasqrl.ai.spring; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@Component -@ConfigurationProperties(prefix = "config") -@Data -public class ServerProperties { - - private String backendUrl; -} diff --git a/acorn-springai/src/main/java/com/datasqrl/ai/spring/SpringAITestApplication.java b/acorn-springai/src/main/java/com/datasqrl/ai/spring/SpringAITestApplication.java deleted file mode 100644 index e2026f7..0000000 --- a/acorn-springai/src/main/java/com/datasqrl/ai/spring/SpringAITestApplication.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.datasqrl.ai.spring; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class SpringAITestApplication { - - public static void main(String[] args) { - SpringApplication.run(SpringAITestApplication.class, args); - } -} diff --git a/coverage/pom.xml b/coverage/pom.xml new file mode 100644 index 0000000..a527df0 --- /dev/null +++ b/coverage/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + com.datasqrl.acorn + acorn-parent + 0.2-SNAPSHOT + + + coverage + Acorn - Report + JaCoCo can’t actually aggregate test reposts, so we have to jump through a bunch of hoops. This is hoop numero uno. + + + + ${project.groupId} + acorn-springai + ${project.version} + + + ${project.groupId} + acorn-graphql + ${project.version} + + + ${project.groupId} + acorn-sqrl-creditcard-rewards + ${project.version} + + + ${project.groupId} + acorn-example-rick-and-morty + ${project.version} + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + report-aggregate + + report-aggregate + + verify + + + + + + diff --git a/coverage/src/main/java/hoop/Code.java b/coverage/src/main/java/hoop/Code.java new file mode 100644 index 0000000..ea6aaef --- /dev/null +++ b/coverage/src/main/java/hoop/Code.java @@ -0,0 +1,7 @@ +package hoop; + +/** + * JaCoCo can’t actually aggregate test reposts, so we have to jump through a bunch of hoops. This + * is hoop numero tres. + */ +public class Code {} diff --git a/coverage/src/test/java/packagereport/ReportTest.java b/coverage/src/test/java/packagereport/ReportTest.java new file mode 100644 index 0000000..afd7286 --- /dev/null +++ b/coverage/src/test/java/packagereport/ReportTest.java @@ -0,0 +1,13 @@ +package packagereport; + +import org.junit.Test; + +/** + * JaCoCo can’t actually aggregate test reposts, so we have to jump through a bunch of hoops. This + * is hoop numero dos. + */ +public class ReportTest { + + @Test + public void test() {} +} diff --git a/img/acorn_diagram.svg b/img/acorn_diagram.svg new file mode 100644 index 0000000..dfde3dd --- /dev/null +++ b/img/acorn_diagram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pom.xml b/pom.xml index ad99038..ba96176 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,8 @@ acorn-graphql acorn-springai + acorn-examples + coverage @@ -61,7 +63,6 @@ 17 17 - 5.8.2 1.18.34 2.17.1 0.29.0 @@ -72,12 +73,39 @@ 22.0 0.10.2 4.12.0 - 6.1.11 + 3.3.0 + 1.4.0 2.2.224 5.3 3.5.2 + + + + org.testcontainers + testcontainers-bom + 1.20.6 + pom + import + + + org.junit + junit-bom + 5.12.1 + pom + import + + + org.springframework.ai + spring-ai-bom + 1.0.0-M6 + pom + import + + + + @@ -91,10 +119,19 @@ slf4j-api ${slf4j.version} + + org.junit.platform + junit-platform-launcher + test + + + org.junit.vintage + junit-vintage-engine + test + org.junit.jupiter junit-jupiter-engine - ${junit.jupiter.version} test @@ -274,6 +311,26 @@ + + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + prepare-agent + + prepare-agent + + + + report + + report + + + + diff --git a/server-start.sh b/server-start.sh deleted file mode 100755 index bf5c20f..0000000 --- a/server-start.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -if [ -z "$1" ] || [ -z "$2" ]; then - echo "ERROR: Two arguments expected: 1) configuration file and 2) tools file" - exit 1 -fi - -java -jar /app/server.jar --agent.config=$1 --agent.tools=$2 \ No newline at end of file