├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── documentation.yml │ └── feature-request.yml └── workflows │ ├── ci.yaml │ ├── javadocs.yaml │ ├── release.yaml │ └── snapshot.yaml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── jai-distributed-architecture.png ├── jai-standalone-architecture.png ├── jai-worflow-anatomy.png ├── jai-workflow-playground.gif └── workflow-code-black.png ├── jai-workflow-core ├── image │ ├── definition-branching-jai-workflow-beauty.svg │ ├── definition-branching-jai-workflow-sketchy.svg │ ├── my-computed-workflow-beauty.svg │ ├── my-workflow-beauty.svg │ ├── my-workflow-sketchy-from-test.svg │ └── my-workflow.svg ├── pom.xml └── src │ ├── main │ └── java │ │ └── io │ │ └── github │ │ └── czelabueno │ │ └── jai │ │ └── workflow │ │ ├── DefaultStateWorkflow.java │ │ ├── StateWorkflow.java │ │ ├── WorkflowStateName.java │ │ ├── graph │ │ ├── Format.java │ │ ├── GraphImageGenerator.java │ │ ├── StyleAttribute.java │ │ └── graphviz │ │ │ ├── GraphvizImageGenerator.java │ │ │ ├── Orientation.java │ │ │ └── StyleGraph.java │ │ ├── node │ │ ├── Conditional.java │ │ └── Node.java │ │ └── transition │ │ ├── ComputedTransition.java │ │ ├── Transition.java │ │ └── TransitionState.java │ └── test │ ├── java │ └── io │ │ └── github │ │ └── czelabueno │ │ └── jai │ │ └── workflow │ │ ├── StateWorkflowTest.java │ │ ├── graph │ │ └── graphviz │ │ │ └── GraphvizImageGeneratorTest.java │ │ ├── node │ │ ├── ConditionalTest.java │ │ └── NodeTest.java │ │ └── transition │ │ ├── ComputedTransitionTest.java │ │ └── TransitionTest.java │ └── resources │ └── tinylog.properties ├── jai-workflow-langchain4j ├── pom.xml └── src │ ├── main │ └── java │ │ └── io │ │ └── github │ │ └── czelabueno │ │ └── jai │ │ └── workflow │ │ └── langchain4j │ │ ├── AbstractStatefulBean.java │ │ ├── JAiWorkflow.java │ │ ├── internal │ │ └── DefaultJAiWorkflow.java │ │ └── node │ │ └── StreamingNode.java │ └── test │ ├── java │ ├── JAiWorkflowIT.java │ ├── JAiWorkflowTest.java │ └── io │ │ └── github │ │ └── czelabueno │ │ └── jai │ │ └── workflow │ │ └── langchain4j │ │ ├── node │ │ └── StreamingNodeTest.java │ │ └── workflow │ │ ├── NodeFunctionsMock.java │ │ ├── StatefulBeanMock.java │ │ └── prompt │ │ └── GenerateAnswerPrompt.java │ └── resources │ └── tinylog.properties └── pom.xml /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41B Bug Report" 2 | description: Report a bug in jAI-workflow. Please provide as much information as possible to help us reproduce the bug. 3 | title: "[BUG] " 4 | labels: ["bug"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to fill out this bug report! 11 | 12 | Use this to report BUGS in jAI Workflow. For usage questions and general design questions, please use [GitHub Discussions](https://github.com/czelabueno/jai-workflow/discussions). 13 | - type: checkboxes 14 | id: checks 15 | attributes: 16 | label: Checked other resources 17 | description: Before submitting this issue, please confirm that you have completed all the steps below by checking each option. These steps help ensure your issue is well-defined, relevant, and actionable. 18 | options: 19 | - label: This is a bug, not a usage question. For questions, please use GitHub Discussions. 20 | required: true 21 | - label: I added a clear and detailed title that summarizes the issue. 22 | required: true 23 | - label: I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example). 24 | required: true 25 | - label: I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue. 26 | required: true 27 | - type: input 28 | id: title 29 | attributes: 30 | label: Bug Title 31 | description: A clear and concise title of what the bug is. 32 | placeholder: "Bug title" 33 | 34 | - type: textarea 35 | id: description 36 | attributes: 37 | label: Describe the Bug 38 | description: A clear and concise description of what the bug is. 39 | placeholder: "Description of the bug" 40 | 41 | - type: textarea 42 | id: reproduction 43 | attributes: 44 | label: To Reproduce 45 | description: | 46 | Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case. 47 | placeholder: | 48 | import io.github.czelabueno.jai.workflow.DefaultStateWorkflow; 49 | import io.github.czelabueno.jai.workflow.StateWorkflow; 50 | import io.github.czelabueno.jai.workflow.node.Node; 51 | 52 | import static io.github.czelabueno.jai.workflow.WorkflowStateName.START; 53 | import static io.github.czelabueno.jai.workflow.WorkflowStateName.END; 54 | 55 | StateWorkflow jAiWorkflow = DefaultStateWorkflow.builder() 56 | .statefulBean(myStatefulBean) 57 | .addNodes(asList(node1, node2)) 58 | .build(); 59 | 60 | jAiWorkflow.putEdge(START, node1); 61 | jAiWorkflow.putEdge(node1, node2); 62 | jAiWorkflow.putEdge(node2, END); 63 | 64 | jAiWorkflow.run("Hello, World!"); 65 | render: java 66 | 67 | - type: textarea 68 | id: expected 69 | attributes: 70 | label: Expected Behavior 71 | description: A clear and concise description of what you expected to happen. 72 | placeholder: "Expected behavior" 73 | 74 | - type: textarea 75 | id: logs 76 | attributes: 77 | label: Logs and Stack Traces (if applicable) 78 | description: | 79 | If you are reporting an error, please include the full error message and stack trace. 80 | placeholder: | 81 | Exception + full stack trace 82 | render: shell 83 | 84 | - type: input 85 | id: environment 86 | attributes: 87 | label: Environment 88 | description: | 89 | Please provide the following information: 90 | - jAI Workflow Core version: [e.g. 0.2.0] 91 | - jAI Workflow LangChain4j version : [e.g. 0.2.0] 92 | - Java version: [e.g. 21] 93 | - LangChain4j version (if applicable): [e.g. 1.0.0-alpha] 94 | - Spring Boot version (if applicable): 95 | placeholder: "Environment details" 96 | 97 | - type: textarea 98 | id: additional 99 | attributes: 100 | label: Additional Context 101 | description: Add any other context about the problem here. 102 | placeholder: "Additional context" 103 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F4D6 Documentation" 2 | description: Request for new documentation or improvements to existing documentation for jAI Workflow. Please provide as much detail as possible to help us understand your request. 3 | title: "[DOCS] " 4 | labels: ["documentation"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for suggesting improvements to the documentation for jAI Workflow! 11 | 12 | Please use this template to provide a detailed description of the documentation request. For general questions or discussions, please use [GitHub Discussions](https://github.com/czelabueno/jai-workflow/discussions). 13 | 14 | - type: input 15 | id: title 16 | attributes: 17 | label: Documentation Title 18 | description: A clear and concise title of the documentation request. 19 | placeholder: "Documentation title" 20 | 21 | - type: textarea 22 | id: motivation 23 | attributes: 24 | label: Motivation 25 | description: Explain why this documentation should be added or improved and how it would benefit users. 26 | placeholder: "Motivation for the documentation request" 27 | 28 | - type: textarea 29 | attributes: 30 | label: "Issue with current documentation:" 31 | description: | 32 | Please make sure to leave a reference to the document/code you're 33 | referring to. 34 | 35 | - type: textarea 36 | attributes: 37 | label: "Idea or request for content:" 38 | description: | 39 | Please describe as clearly as possible what topics you think are missing 40 | from the current documentation. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F680 Feature Request" 2 | description: Suggest a new feature or enhancement for jAI Workflow. Please provide as much detail as possible to help us understand your request. 3 | title: "[FEATURE] " 4 | labels: ["enhancement"] 5 | 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for suggesting a feature for jAI Workflow! 11 | 12 | Please use this template to provide a detailed description of the feature you would like to see. For general questions or discussions, please use [GitHub Discussions](https://github.com/czelabueno/jai-workflow/discussions). 13 | 14 | - type: input 15 | id: title 16 | attributes: 17 | label: Feature Title 18 | description: A clear and concise title of the feature request. 19 | placeholder: "Feature title" 20 | 21 | - type: textarea 22 | id: description 23 | attributes: 24 | label: Description 25 | description: A clear and concise description of what the feature is and why it would be useful. 26 | placeholder: "Description of the feature" 27 | 28 | - type: textarea 29 | id: motivation 30 | attributes: 31 | label: Motivation 32 | description: Explain why this feature should be added and how it would benefit users. 33 | placeholder: "Motivation for the feature" 34 | 35 | - type: textarea 36 | id: alternatives 37 | attributes: 38 | label: Alternatives 39 | description: Describe any alternative solutions or features you've considered. 40 | placeholder: "Alternative solutions or features" 41 | 42 | - type: textarea 43 | id: additional 44 | attributes: 45 | label: Additional Context 46 | description: Add any other context or screenshots about the feature request here. 47 | placeholder: "Additional context" 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: jAI Workflow CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '.gitignore' 9 | - '*.md' 10 | - 'LICENSE' 11 | - '.github/*.md' 12 | - '.github/*.yml' 13 | pull_request: 14 | branches: 15 | - main 16 | paths-ignore: 17 | - '.gitignore' 18 | - '*.md' 19 | - 'LICENSE' 20 | - '.github/*.md' 21 | - '.github/*.yml' 22 | workflow_dispatch: 23 | 24 | jobs: 25 | java_build: 26 | strategy: 27 | matrix: 28 | java_version: ['17', '21'] 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - name: Set up JDK ${{ matrix.java_version }} 33 | uses: actions/setup-java@v4 34 | with: 35 | java-version: ${{ matrix.java_version }} 36 | distribution: 'temurin' 37 | cache: maven 38 | - name: Build and test with JDK ${{ matrix.java_version }} 39 | run: | 40 | mvn -B -U -T4C clean test \ 41 | -Dmaven.javadoc.skip=true \ 42 | -DskipITs=true surefire-report:report 43 | - name: Upload Surefire Reports with JDK ${{ matrix.java_version }} 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: test-surefire-reports-jdk-${{ matrix.java_version }} 47 | path: '**/target/surefire-reports/*.xml' 48 | - name: Publish Test Report 49 | uses: mikepenz/action-junit-report@v4 50 | if: success() || failure() # always run even if the previous step fails 51 | with: 52 | report_paths: '**/target/*-reports/TEST-*.xml' 53 | annotate_only: true 54 | 55 | 56 | -------------------------------------------------------------------------------- /.github/workflows/javadocs.yaml: -------------------------------------------------------------------------------- 1 | name: Build and publish Java Docs to GitHub Pages 2 | 3 | on: 4 | release: 5 | types: [created] 6 | workflow_dispatch: 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: "pages" 16 | cancel-in-progress: false 17 | 18 | jobs: 19 | deploy: 20 | runs-on: ubuntu-latest 21 | environment: 22 | name: github-pages 23 | url: ${{ steps.deployment.outputs.page_url }} 24 | steps: 25 | - name: Checkout source code 26 | uses: actions/checkout@v4 27 | - name: Set up JDK 21 28 | uses: actions/setup-java@v4 29 | with: 30 | java-version: '21' 31 | distribution: 'temurin' 32 | - name: Generate Javadoc 33 | run: mvn -T4C compile javadoc:aggregate -DskipTests 34 | 35 | - name: Setup Pages 36 | uses: actions/configure-pages@v3 37 | - name: Upload artifact 38 | uses: actions/upload-pages-artifact@v2 39 | with: 40 | # Upload side folder 41 | path: './target/site/apidocs' 42 | - name: Deploy to GitHub Pages 43 | id: deployment 44 | uses: actions/deploy-pages@v2 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: jAI Workflow Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v0.[0-9].[0-9]+ 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | 12 | jobs: 13 | release: 14 | name: Build and deploy to Maven Central 15 | if: github.repository == 'czelabueno/jai-workflow' 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up JDK 21 21 | uses: actions/setup-java@v4 22 | with: 23 | java-version: '21' 24 | distribution: 'temurin' 25 | cache: maven 26 | server-id: ossrh 27 | server-username: OSSRH_USERNAME 28 | server-password: OSSRH_PASSWORD 29 | gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} 30 | gpg-passphrase: GPG_PASSPHRASE 31 | - name: Publish to Sonatype Maven Central 32 | run: | 33 | mvn versions:set -DnewVersion=${GITHUB_REF#refs/tags/v} 34 | mvn -B -U --fail-at-end -Prelease clean deploy -DskipTests 35 | env: 36 | GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} 37 | OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} 38 | OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} 39 | create-github-release: 40 | name: Create GitHub Release 41 | needs: release 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Create GitHub Release 45 | shell: bash 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | tag: ${{ github.ref_name }} 49 | run: | 50 | gh release create "$tag" \ 51 | --repo "${{ github.repository }}" \ 52 | --title "$tag" \ 53 | --generate-notes \ 54 | --latest 55 | -------------------------------------------------------------------------------- /.github/workflows/snapshot.yaml: -------------------------------------------------------------------------------- 1 | name: jAI Workflow Snapshot-release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | snapshot-release: 11 | if: github.repository == 'czelabueno/jai-workflow' 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup JDK 21 17 | uses: actions/setup-java@v4 18 | with: 19 | java-version: '21' 20 | distribution: 'temurin' 21 | cache: maven 22 | server-id: ossrh 23 | server-username: OSSRH_USERNAME 24 | server-password: OSSRH_PASSWORD 25 | - name: Publish SNAPSHOT to Sonatype 26 | run: | 27 | mvn -B --fail-at-end \ 28 | -DskipTests -DskipITs \ 29 | clean deploy 30 | env: 31 | OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} 32 | OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | 3 | ### IntelliJ IDEA ### 4 | .idea/* 5 | .idea/modules.xml 6 | .idea/jarRepositories.xml 7 | .idea/compiler.xml 8 | .idea/libraries/ 9 | *.iws 10 | *.iml 11 | *.ipr 12 | 13 | ### Eclipse ### 14 | .apt_generated 15 | .classpath 16 | .factorypath 17 | .project 18 | .settings 19 | .springBeans 20 | .sts4-cache 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | 35 | ### Mac OS ### 36 | .DS_Store 37 | 38 | ### .env files contain local environment variables ### 39 | .env 40 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to jAI Workflow 2 | 3 | Thank you for considering contributing to jAI Workflow! We welcome contributions from the community and are excited to work with you. 4 | 5 | ## Getting Started 6 | 7 | 1. **Fork the repository**: Click the "Fork" button at the top right of the repository page. 8 | 2. **Clone your fork**: 9 | ```sh 10 | git clone https://github.com/your-username/jai-workflow.git 11 | cd jai-workflow 12 | ``` 13 | 3. **Create a branch**: 14 | ```sh 15 | git checkout -b feature/your-feature-name 16 | ``` 17 | 18 | 1. **Install dependencies**: 19 | ```sh 20 | mvn clean install 21 | ``` 22 | 23 | 2. **Build the project**: 24 | ```sh 25 | mvn package 26 | ``` 27 | 3. **Running Tests**: 28 | ```sh 29 | mvn test 30 | ``` 31 | ## General guidelines 32 | Here are some things to keep in mind for all types of contributions: 33 | 34 | - Follow the ["fork and pull request"](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project) workflow. 35 | - Fill out the checked-in pull request template when opening pull requests. Note related issues and tag maintainers. 36 | - Ensure your PR passes build and testing checks before requesting a review. 37 | - If you would like comments or feedback, please open an issue or discussion and tag a maintainer. 38 | - Backwards compatibility is key. Your changes must not be breaking, except in case of critical bug and security fixes. 39 | - Look for duplicate PRs or issues that have already been opened before opening a new one. 40 | - Keep scope as isolated as possible. As a general rule, your changes should not affect more than one package at a time. 41 | 42 | ### Priorities 43 | All [issues](https://github.com/czelabueno/jai-workflow/issues) are prioritized by maintainers. These will be reviewed in order of priority, with bugs being a higher priority than new features. 44 | 45 | We have organized releases by year QUARTER. All features and bug fixes will be released progressively for testing and feedback in the corresponding quarter. 46 | 47 | For example, the Q1-25 is planned to release the following feature list and we expect PRs related to these features to be merged by the end of Q1-25. The list is as follows: 48 | 49 | #### Q1 2025 Features 50 | - **Graph-Core**: 51 | - Split Nodes 52 | - Merge Nodes 53 | - Parallel transitions 54 | - Human-in-the-loop 55 | - **Modular (Group of nodes)**: 56 | - Module 57 | - Remote Module 58 | - **Integration**: 59 | - Model Context Protocol (MCP) integration as server and client. 60 | - Define remote module as MCP server. 61 | - **API**: 62 | - Publish workflow as API (SSE for streaming runs and REST for sync runs). 63 | 64 | ### BugFixes 65 | For bug fixes, please open up an issue before proposing a fix to ensure the proposal properly addresses the underlying problem. In general, bug fixes should all have an accompanying unit test that fails before the fix. 66 | 67 | ### New Features 68 | For new features, please open up an issue before proposing a new feature to ensure the proposal aligns with the project's goals. Fill out the checked-in feature request template. 69 | 70 | ## Making Changes 71 | 1. **Write clear, concise commit messages:** Follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. 72 | 2. **Ensure your code follows the style guidelines:** Use the existing code as a reference. 73 | 3. **Add tests:** Ensure that your changes are covered by tests. 74 | 4. **Update the documentation:** If your changes affect the existing JavaDoc, update the documentation accordingly. 75 | 76 | ## Submitting Changes 77 | 1. **Push your changes:** 78 | ```sh 79 | git push origin feature/your-feature-name 80 | ``` 81 | 2. **Create a Pull Request:** Go to the repository on GitHub and click "New Pull Request". Fill out the template provided. 82 | 83 | ## Code of Conduct 84 | Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project, you agree to abide by its terms. 85 | 86 | ## Additional Resources 87 | - [Issue Tracker](https://github.com/czelabueno/jai-workflow/issues) 88 | - [GitHub Discussions](https://github.com/czelabueno/jai-workflow/discussions) 89 | - [Maven Documentation](https://maven.apache.org/guides/index.html) 90 | 91 | Thank you for your contributions! 🎉 92 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /docs/jai-distributed-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czelabueno/jai-workflow/864bef04bf92990295c169feebb908e791a39fe2/docs/jai-distributed-architecture.png -------------------------------------------------------------------------------- /docs/jai-standalone-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czelabueno/jai-workflow/864bef04bf92990295c169feebb908e791a39fe2/docs/jai-standalone-architecture.png -------------------------------------------------------------------------------- /docs/jai-worflow-anatomy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czelabueno/jai-workflow/864bef04bf92990295c169feebb908e791a39fe2/docs/jai-worflow-anatomy.png -------------------------------------------------------------------------------- /docs/jai-workflow-playground.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czelabueno/jai-workflow/864bef04bf92990295c169feebb908e791a39fe2/docs/jai-workflow-playground.gif -------------------------------------------------------------------------------- /docs/workflow-code-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/czelabueno/jai-workflow/864bef04bf92990295c169feebb908e791a39fe2/docs/workflow-code-black.png -------------------------------------------------------------------------------- /jai-workflow-core/image/my-computed-workflow-beauty.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | workflow 5 | 6 | 7 | 8 | _start_ 9 | 10 | 11 | 12 | 13 | 14 | _start_ 15 | 16 | 17 | 18 | node1 19 | 20 | node1 21 | 22 | 23 | 24 | _start_->node1 25 | 26 | 27 | 28 | 29 | 30 | node2 31 | 32 | node2 33 | 34 | 35 | 36 | node1->node2 37 | 38 | 39 | 40 | 41 | 42 | node3 43 | 44 | node3 45 | 46 | 47 | 48 | node2->node3 49 | 50 | 51 | 52 | 53 | 54 | node4 55 | 56 | node4 57 | 58 | 59 | 60 | _end_ 61 | 62 | 63 | 64 | 65 | 66 | _end_ 67 | 68 | 69 | 70 | node4->_end_ 71 | 72 | 73 | 74 | 75 | 76 | greaterThan6 77 | 78 | greater than 6? 79 | 80 | 81 | 82 | node3->greaterThan6 83 | 84 | 85 | 86 | 87 | 88 | greaterThan6->node2 89 | 90 | 91 | 92 | 93 | 94 | greaterThan6->node4 95 | 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /jai-workflow-core/image/my-workflow-beauty.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | workflow 5 | 6 | 7 | 8 | _start_ 9 | 10 | 11 | 12 | 13 | 14 | _start_ 15 | 16 | 17 | 18 | node1 19 | 20 | node1 21 | 22 | 23 | 24 | _start_->node1 25 | 26 | 27 | 28 | 29 | 30 | node2 31 | 32 | node2 33 | 34 | 35 | 36 | node1->node2 37 | 38 | 39 | 40 | 41 | 42 | node3 43 | 44 | node3 45 | 46 | 47 | 48 | node2->node3 49 | 50 | 51 | 52 | 53 | 54 | node4 55 | 56 | node4 57 | 58 | 59 | 60 | _end_ 61 | 62 | 63 | 64 | 65 | 66 | _end_ 67 | 68 | 69 | 70 | node4->_end_ 71 | 72 | 73 | 74 | 75 | 76 | greaterThan6 77 | 78 | greater than 6? 79 | 80 | 81 | 82 | node3->greaterThan6 83 | 84 | 85 | 86 | 87 | 88 | greaterThan6->node2 89 | 90 | 91 | 92 | 93 | 94 | greaterThan6->node4 95 | 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /jai-workflow-core/image/my-workflow.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | workflow 5 | 6 | 7 | 8 | start 9 | 10 | 11 | 12 | 13 | 14 | start 15 | 16 | 17 | 18 | Node1 19 | 20 | Node1 21 | 22 | 23 | 24 | start->Node1 25 | 26 | 27 | 28 | 29 | 30 | Node2 31 | 32 | Node2 33 | 34 | 35 | 36 | Node1->Node2 37 | 38 | 39 | 40 | 41 | 42 | Node3 43 | 44 | Node3 45 | 46 | 47 | 48 | Node2->Node3 49 | 50 | 51 | 52 | 53 | 54 | Node2->Node3 55 | 56 | 57 | 58 | 59 | 60 | Node3->Node2 61 | 62 | 63 | 64 | 65 | 66 | Node4 67 | 68 | Node4 69 | 70 | 71 | 72 | Node3->Node4 73 | 74 | 75 | 76 | 77 | 78 | end 79 | 80 | 81 | 82 | 83 | 84 | end 85 | 86 | 87 | 88 | Node4->end 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /jai-workflow-core/pom.xml: -------------------------------------------------------------------------------- 1 | 3 | 4.0.0 4 | 5 | io.github.czelabueno 6 | jai-workflow-parent 7 | 0.3.0-SNAPSHOT 8 | ../pom.xml 9 | 10 | 11 | jai-workflow-core 12 | JavAI Workflow :: Core 13 | https://github.com/czelabueno/langchain4j-workflow 14 | jAI Workflow: Build advanced agentic java AI applications based on workflows 15 | 16 | 17 | 18 | org.projectlombok 19 | lombok 20 | provided 21 | 22 | 23 | 24 | guru.nidi 25 | graphviz-java 26 | 27 | 28 | 29 | guru.nidi 30 | graphviz-rough 31 | 32 | 33 | 34 | org.graalvm.js 35 | js 36 | runtime 37 | 38 | 39 | 40 | org.slf4j 41 | slf4j-api 42 | 43 | 44 | 45 | 46 | org.junit.jupiter 47 | junit-jupiter-engine 48 | ${junit.version} 49 | test 50 | 51 | 52 | 53 | org.mockito 54 | mockito-junit-jupiter 55 | test 56 | 57 | 58 | 59 | org.assertj 60 | assertj-core 61 | ${assertj.version} 62 | test 63 | 64 | 65 | 66 | org.tinylog 67 | tinylog-impl 68 | test 69 | 70 | 71 | 72 | org.tinylog 73 | slf4j-tinylog 74 | test 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/StateWorkflow.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow; 2 | 3 | import io.github.czelabueno.jai.workflow.graph.Format; 4 | import io.github.czelabueno.jai.workflow.graph.StyleAttribute; 5 | import io.github.czelabueno.jai.workflow.node.Conditional; 6 | import io.github.czelabueno.jai.workflow.node.Node; 7 | import io.github.czelabueno.jai.workflow.transition.ComputedTransition; 8 | 9 | import java.awt.image.BufferedImage; 10 | import java.io.IOException; 11 | import java.util.List; 12 | import java.util.function.Consumer; 13 | 14 | /** 15 | * Interface representing a state workflow. 16 | * 17 | * @param the type of the stateful bean used in the workflow 18 | */ 19 | public interface StateWorkflow { 20 | 21 | /** 22 | * Adds a node to the workflow. 23 | * 24 | * @param node the node to add 25 | */ 26 | void addNode(Node node); 27 | 28 | /** 29 | * Creates an edge between two nodes in the workflow. 30 | * 31 | * @param from the starting node of the edge 32 | * @param to the ending node of the edge 33 | */ 34 | void putEdge(Node from, Node to); 35 | 36 | /** 37 | * Creates an edge between a node and a conditional node in the workflow. 38 | * 39 | * @param from the starting node of the edge 40 | * @param conditional the conditional node to evaluate 41 | */ 42 | void putEdge(Node from, Conditional conditional); 43 | 44 | /** 45 | * Creates an edge between a node and a workflow state in the workflow. 46 | * 47 | * @param from the starting node of the edge 48 | * @param state the workflow state to transition to 49 | */ 50 | void putEdge(Node from, WorkflowStateName state); 51 | 52 | /** 53 | * Sets the starting node of the workflow. 54 | * 55 | * @param startNode the starting node 56 | * @return the state workflow with the starting node set 57 | */ 58 | StateWorkflow startNode(Node startNode); 59 | 60 | /** 61 | * Returns the last node defined in the workflow. 62 | * 63 | * @return the last node defined in the workflow 64 | */ 65 | Node getLastNode(); 66 | 67 | /** 68 | * Runs the workflow synchronously. 69 | * 70 | * @return the stateful bean after the workflow execution 71 | */ 72 | T run(); 73 | 74 | /** 75 | * Runs the workflow in stream mode, consuming events with the specified consumer. 76 | * 77 | * @param eventConsumer the consumer to process node events 78 | * @return the stateful bean after the workflow execution 79 | */ 80 | T runStream(Consumer> eventConsumer); 81 | 82 | /** 83 | * Returns the list of computed transitions in the workflow. 84 | * 85 | * @return the list of computed transitions 86 | */ 87 | List getComputedTransitions(); 88 | 89 | /** 90 | * Generates an image of the workflow and saves it to the specified output path. 91 | * 92 | * @param format the format of the image 93 | * @param outputPath the path to save the workflow image 94 | * @param styleAttributes the style attributes to apply to the workflow image 95 | * @throws IOException if an I/O error occurs 96 | */ 97 | void generateWorkflowImage(Format format, String outputPath, List styleAttributes) throws IOException; 98 | 99 | /** 100 | * Generates an image of the workflow and saves it to the specified output path. 101 | * 102 | * @param outputPath the path to save the workflow image 103 | * @param styleAttributes the style attributes to apply to the workflow image 104 | * @throws IOException if an I/O error occurs 105 | */ 106 | default void generateWorkflowImage(String outputPath, List styleAttributes) throws IOException { 107 | generateWorkflowImage(Format.SVG, outputPath, styleAttributes); 108 | } 109 | 110 | /** 111 | * Generates an image of the workflow and saves it to the specified output path. 112 | * 113 | * @param format the format of the image 114 | * @param outputPath the path to save the workflow image 115 | * @throws IOException if an I/O error occurs 116 | */ 117 | default void generateWorkflowImage(Format format, String outputPath) throws IOException { 118 | generateWorkflowImage(format, outputPath, List.of()); 119 | } 120 | 121 | /** 122 | * Generates an image of the workflow and saves it to the specified output path. 123 | * 124 | * @param outputPath the path to save the workflow image 125 | * @throws IOException if an I/O error occurs 126 | */ 127 | default void generateWorkflowImage(String outputPath) throws IOException { 128 | generateWorkflowImage(Format.SVG, outputPath); 129 | } 130 | 131 | /** 132 | * Generates an image of the workflow and saves it to the default path "workflow-image.svg". 133 | * 134 | * @throws IOException if an I/O error occurs 135 | */ 136 | default void generateWorkflowImage() throws IOException { 137 | generateWorkflowImage("workflow-image.svg"); 138 | } 139 | 140 | /** 141 | * Generates a BufferedImage representation of the workflow graph. 142 | * 143 | * @param format the format of the image 144 | * @param styleAttributes the style attributes to apply to the workflow image 145 | * @return the BufferedImage representation of the workflow graph 146 | * @throws RuntimeException if an error occurs during image generation 147 | */ 148 | BufferedImage generateWorkflowBufferedImage(Format format, List styleAttributes) throws RuntimeException; 149 | 150 | /** 151 | * Generates a BufferedImage representation of the workflow graph with Format.SVG. by default. 152 | * 153 | * @param styleAttributes the style attributes to apply to the workflow image 154 | * @return the BufferedImage representation of the workflow graph 155 | * @throws RuntimeException if an error occurs during image generation 156 | */ 157 | default BufferedImage generateWorkflowBufferedImage(List styleAttributes) throws RuntimeException { 158 | return generateWorkflowBufferedImage(Format.SVG, styleAttributes); 159 | } 160 | 161 | /** 162 | * Generates a BufferedImage representation of the workflow graph with the specified format. 163 | * 164 | * @param format the format of the image 165 | * @return the BufferedImage representation of the workflow graph 166 | * @throws RuntimeException if an error occurs during image generation 167 | */ 168 | default BufferedImage generateWorkflowBufferedImage(Format format) throws RuntimeException { 169 | return generateWorkflowBufferedImage(format, List.of()); 170 | } 171 | 172 | /** 173 | * Generates a BufferedImage representation of the workflow graph with Format.SVG. by default. 174 | * 175 | * @return the BufferedImage representation of the workflow graph 176 | * @throws RuntimeException if an error occurs during image generation 177 | */ 178 | default BufferedImage generateWorkflowBufferedImage() throws RuntimeException { 179 | return generateWorkflowBufferedImage(Format.SVG); 180 | } 181 | 182 | /** 183 | * Generates an image of the computed workflow and saves it to the specified output path. 184 | * 185 | * @param format the format of the image 186 | * @param outputPath the path to save the workflow image 187 | * @param styleAttributes the style attributes to apply to the workflow image 188 | * @throws IOException if an I/O error occurs 189 | */ 190 | void generateComputedWorkflowImage(Format format, String outputPath, List styleAttributes) throws IOException; 191 | 192 | /** 193 | * Generates an image of the computed workflow and saves it to the specified output path with Format.SVG by default. 194 | * 195 | * @param outputPath the path to save the workflow image 196 | * @param styleAttributes the style attributes to apply to the workflow image 197 | * @throws IOException if an I/O error occurs 198 | */ 199 | default void generateComputedWorkflowImage(String outputPath, List styleAttributes) throws IOException { 200 | generateComputedWorkflowImage(Format.SVG, outputPath, styleAttributes); 201 | } 202 | 203 | /** 204 | * Generates an image of the computed workflow and saves it to the specified output path. 205 | * 206 | * @param format the format of the image 207 | * @param outputPath the path to save the workflow image 208 | * @throws IOException if an I/O error occurs 209 | */ 210 | default void generateComputedWorkflowImage(Format format, String outputPath) throws IOException { 211 | generateComputedWorkflowImage(format, outputPath, List.of()); 212 | } 213 | 214 | /** 215 | * Generates an image of the computed workflow and saves it to the specified output path with Format.SVG by default. 216 | * 217 | * @param outputPath the path to save the workflow image 218 | * @throws IOException if an I/O error occurs 219 | */ 220 | default void generateComputedWorkflowImage(String outputPath) throws IOException { 221 | generateComputedWorkflowImage(Format.SVG, outputPath); 222 | } 223 | 224 | /** 225 | * Generates an image of the computed workflow and saves it to the default path "computed-workflow-image.svg" in SVG format. 226 | * 227 | * @throws IOException if an I/O error occurs 228 | */ 229 | default void generateComputedWorkflowImage() throws IOException { 230 | generateComputedWorkflowImage("computed-workflow-image.svg"); 231 | } 232 | 233 | /** 234 | * Generates a BufferedImage representation of the computed workflow graph. 235 | * 236 | * @param format the format of the image 237 | * @param styleAttributes the style attributes to apply to the workflow image 238 | * @return the BufferedImage representation of the computed workflow graph 239 | * @throws RuntimeException if an error occurs during image generation 240 | */ 241 | BufferedImage generateComputedWorkflowBufferedImage(Format format, List styleAttributes) throws RuntimeException; 242 | 243 | /** 244 | * Generates a BufferedImage representation of the computed workflow graph with Format.SVG by default. 245 | * 246 | * @param styleAttributes the style attributes to apply to the workflow image 247 | * @return the BufferedImage representation of the computed workflow graph 248 | * @throws RuntimeException if an error occurs during image generation 249 | */ 250 | default BufferedImage generateComputedWorkflowBufferedImage(List styleAttributes) throws RuntimeException { 251 | return generateComputedWorkflowBufferedImage(Format.SVG, styleAttributes); 252 | } 253 | 254 | /** 255 | * Generates a BufferedImage representation of the computed workflow graph with the specified format. 256 | * 257 | * @param format the format of the image 258 | * @return the BufferedImage representation of the computed workflow graph 259 | * @throws RuntimeException if an error occurs during image generation 260 | */ 261 | default BufferedImage generateComputedWorkflowBufferedImage(Format format) throws RuntimeException { 262 | return generateComputedWorkflowBufferedImage(format, List.of()); 263 | } 264 | 265 | /** 266 | * Generates a BufferedImage representation of the computed workflow graph with Format.SVG by default. 267 | * 268 | * @return the BufferedImage representation of the computed workflow graph 269 | * @throws RuntimeException if an error occurs during image generation 270 | */ 271 | default BufferedImage generateComputedWorkflowBufferedImage() throws RuntimeException { 272 | return generateComputedWorkflowBufferedImage(Format.SVG); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/WorkflowStateName.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow; 2 | 3 | import io.github.czelabueno.jai.workflow.transition.TransitionState; 4 | 5 | import java.util.List; 6 | 7 | /** 8 | * Enum representing the possible states in a workflow. 9 | *

10 | * This class implements the {@link TransitionState} interface. 11 | */ 12 | public enum WorkflowStateName implements TransitionState { 13 | /** 14 | * The starting state of the workflow. 15 | */ 16 | START("_start_"), 17 | 18 | /** 19 | * The ending state of the workflow. 20 | */ 21 | END("_end_"); 22 | 23 | private final String graphName; 24 | 25 | WorkflowStateName(String graphName) { 26 | this.graphName = graphName; 27 | } 28 | 29 | @Override 30 | public String graphName() { 31 | return graphName; 32 | } 33 | 34 | @Override 35 | public List labels() { 36 | return List.of(graphName); 37 | } 38 | 39 | @Override 40 | public Object input() { 41 | return Void.TYPE; 42 | } 43 | 44 | @Override 45 | public Object output() { 46 | return Void.TYPE; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/graph/Format.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.graph; 2 | 3 | /** 4 | * Enum representing the format of the graph. 5 | * It can be either SVG or PNG. 6 | */ 7 | public enum Format { 8 | /** 9 | * SVG format. 10 | */ 11 | SVG, 12 | /** 13 | * PNG format. 14 | */ 15 | PNG 16 | } 17 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/graph/GraphImageGenerator.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.graph; 2 | 3 | import io.github.czelabueno.jai.workflow.graph.graphviz.Orientation; 4 | import io.github.czelabueno.jai.workflow.graph.graphviz.StyleGraph; 5 | import io.github.czelabueno.jai.workflow.transition.Transition; 6 | 7 | import java.awt.image.BufferedImage; 8 | import java.io.IOException; 9 | import java.util.List; 10 | 11 | /** 12 | * Interface for generating graph images from workflow transitions computed. 13 | */ 14 | public interface GraphImageGenerator { 15 | 16 | /** 17 | * Generates a graph image from the given list of transitions and saves it to the default output path: /workflow-image.svg 18 | * 19 | * @param transitions the list of transitions to generate the graph image from 20 | * @throws IOException if an I/O error occurs during image generation 21 | */ 22 | default void generateImage(List transitions) throws IOException { 23 | generateImage(transitions, "workflow-image.svg"); 24 | } 25 | 26 | /** 27 | * Generates a graph image with the given format from the given list of transitions and saves it to the default output path. 28 | * 29 | * @param transitions the list of transitions to generate the graph image from 30 | * @param format the format of the generated image 31 | * @throws IOException if an I/O error occurs during image generation 32 | */ 33 | default void generateImage(List transitions, Format format) throws IOException { 34 | generateImage(transitions, "workflow-image." + format.name().toLowerCase(), format); 35 | } 36 | 37 | /** 38 | * Generates a graph image from the given list of transitions and saves it to the given output path. 39 | * 40 | * @param transitions the list of transitions to generate the graph image from 41 | * @param outputPath the path to save the generated graph image 42 | * @param format the format of the generated image 43 | * @param styles the styles to apply to the generated image 44 | * @throws IOException if an I/O error occurs during image generation 45 | */ 46 | void generateImage(List transitions, String outputPath, Format format, StyleAttribute... styles) throws IOException; 47 | 48 | /** 49 | * Generates a graph image with the given format from the given list of transitions and saves it to the specified output path. 50 | * 51 | * @param transitions the list of transitions to generate the graph image from 52 | * @param outputPath the path to save the generated graph image 53 | * @param format the format of the generated image 54 | * @throws IOException if an I/O error occurs during image generation 55 | * @throws IllegalArgumentException if the output path is null or empty 56 | */ 57 | default void generateImage(List transitions, String outputPath, Format format) throws IOException { 58 | generateImage(transitions, outputPath, format, StyleGraph.DEFAULT, Orientation.VERTICAL); 59 | } 60 | 61 | /** 62 | * Generates a graph image with {@link Format}.SVG from the given list of transitions and saves it to the specified output path. 63 | * 64 | * @param transitions the list of transitions to generate the graph image from 65 | * @param outputPath the path to save the generated graph image 66 | * @throws IOException if an I/O error occurs during image generation 67 | * @throws IllegalArgumentException if the output path is null or empty 68 | */ 69 | default void generateImage(List transitions, String outputPath) throws IOException { 70 | generateImage(transitions, outputPath, Format.SVG); 71 | } 72 | 73 | /** 74 | * Generates a BufferedImage representation of the workflow graph from the given list of transitions. 75 | * 76 | * @param transitions the list of transitions to generate the graph image from 77 | * @param format the format of the generated image (e.g., SVG, PNG) 78 | * @param styles optional styles to apply to the graph (e.g., sketchy, orientation) 79 | * @return the generated BufferedImage representation of the workflow graph 80 | * @throws RuntimeException if an error occurs during image generation 81 | */ 82 | BufferedImage generateBufferedImage(List transitions, Format format, StyleAttribute... styles) throws RuntimeException; 83 | 84 | /** 85 | * Generates a BufferedImage representation of the workflow graph from the given list of transitions. 86 | * @param transitions the list of transitions to generate the graph image from 87 | * @param styles optional styles to apply to the graph (e.g., sketchy, orientation) 88 | * @return the generated BufferedImage representation of the workflow graph 89 | * @throws RuntimeException if an error occurs during image generation 90 | */ 91 | default BufferedImage generateBufferedImage(List transitions, StyleAttribute... styles) throws RuntimeException { 92 | return generateBufferedImage(transitions, Format.SVG, styles); 93 | } 94 | 95 | /** 96 | * Generates a BufferedImage representation of the workflow graph from the given list of transitions. 97 | * @param transitions the list of transitions to generate the graph image from 98 | * @param format the format of the generated image (e.g., SVG, PNG) 99 | * @return the generated BufferedImage representation of the workflow graph 100 | * @throws RuntimeException if an error occurs during image generation 101 | */ 102 | default BufferedImage generateBufferedImage(List transitions, Format format) throws RuntimeException { 103 | return generateBufferedImage(transitions, format, StyleGraph.DEFAULT, Orientation.VERTICAL); 104 | } 105 | 106 | /** 107 | * Generates a BufferedImage representation of the workflow graph from the given list of transitions. 108 | * @param transitions the list of transitions to generate the graph image from 109 | * @return the generated BufferedImage representation of the workflow graph 110 | * @throws RuntimeException if an error occurs during image generation 111 | */ 112 | default BufferedImage generateBufferedImage(List transitions) throws RuntimeException { 113 | return generateBufferedImage(transitions, Format.SVG); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/graph/StyleAttribute.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.graph; 2 | 3 | /** 4 | * Interface representing a style attribute. 5 | */ 6 | public interface StyleAttribute { 7 | /** 8 | * Gets the code representing the style attribute. 9 | * 10 | * @return the code representing the style attribute 11 | */ 12 | public String getCode(); 13 | } 14 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/graph/graphviz/GraphvizImageGenerator.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.graph.graphviz; 2 | 3 | import guru.nidi.graphviz.attribute.*; 4 | import guru.nidi.graphviz.model.Graph; 5 | import guru.nidi.graphviz.model.Link; 6 | import guru.nidi.graphviz.rough.FillStyle; 7 | import guru.nidi.graphviz.rough.Roughifyer; 8 | import io.github.czelabueno.jai.workflow.WorkflowStateName; 9 | import io.github.czelabueno.jai.workflow.graph.Format; 10 | import io.github.czelabueno.jai.workflow.graph.StyleAttribute; 11 | import io.github.czelabueno.jai.workflow.node.Conditional; 12 | import io.github.czelabueno.jai.workflow.node.Node; 13 | import io.github.czelabueno.jai.workflow.transition.ComputedTransition; 14 | import io.github.czelabueno.jai.workflow.transition.Transition; 15 | import io.github.czelabueno.jai.workflow.graph.GraphImageGenerator; 16 | import guru.nidi.graphviz.engine.*; 17 | import io.github.czelabueno.jai.workflow.transition.TransitionState; 18 | import org.slf4j.Logger; 19 | import org.slf4j.LoggerFactory; 20 | 21 | import java.awt.image.BufferedImage; 22 | import java.io.File; 23 | import java.io.IOException; 24 | import java.util.*; 25 | import java.util.stream.Collectors; 26 | 27 | import static guru.nidi.graphviz.model.Factory.*; 28 | import static guru.nidi.graphviz.model.Factory.node; 29 | import static io.github.czelabueno.jai.workflow.graph.graphviz.Orientation.HORIZONTAL; 30 | import static java.util.stream.Collectors.joining; 31 | 32 | /** 33 | * Implementation of {@link GraphImageGenerator} that uses Graphviz java library and DOT language to generate workflow images. 34 | */ 35 | public class GraphvizImageGenerator implements GraphImageGenerator { 36 | 37 | private static final Logger log = LoggerFactory.getLogger(GraphvizImageGenerator.class); 38 | private static final Map onceComputedTransition = new HashMap<>(); 39 | 40 | private String dotFormat; 41 | private List computedTransitions; 42 | 43 | private GraphvizImageGenerator(GraphvizImageGeneratorBuilder builder) { 44 | this.dotFormat = builder.dotFormat; 45 | this.computedTransitions = builder.computedTransitions; 46 | } 47 | 48 | /** 49 | * Returns a new builder instance for creating a {@link GraphvizImageGenerator}. 50 | * 51 | * @return a new {@link GraphvizImageGeneratorBuilder} instance 52 | */ 53 | public static GraphvizImageGeneratorBuilder builder() { 54 | return new GraphvizImageGeneratorBuilder(); 55 | } 56 | 57 | /** 58 | * Generates a graph image from the given list of transitions and saves it to the specified output path. 59 | * 60 | * @param transitions the list of transitions to generate the graph image from 61 | * @param outputPath the path to save the generated graph image 62 | * @param format the format of the generated image (e.g., SVG, PNG) 63 | * @param styles optional styles to apply to the graph (e.g., sketchy, orientation) 64 | * @throws IOException if an I/O error occurs during image generation 65 | * @throws IllegalArgumentException if the output path is null or empty 66 | */ 67 | @Override 68 | public void generateImage(List transitions, String outputPath, Format format, StyleAttribute... styles) throws IOException { 69 | if (outputPath == null || outputPath.isEmpty()) { 70 | throw new IllegalArgumentException("Output path can not be null or empty. Cannot generate image."); 71 | } 72 | log.debug("Saving workflow image.."); 73 | createRenderer(transitions, format, styles).toFile(new File(outputPath)); 74 | log.debug("Workflow image saved to: " + outputPath); 75 | } 76 | 77 | /** 78 | * Generates a BufferedImage representation of the workflow graph from the given list of transitions. 79 | * 80 | * @param transitions the list of transitions to generate the graph image from 81 | * @param format the format of the generated image (e.g., SVG, PNG) 82 | * @param styles optional styles to apply to the graph (e.g., sketchy, orientation) 83 | * @return the generated BufferedImage representation of the workflow graph 84 | * @throws RuntimeException if an error occurs during image generation 85 | */ 86 | @Override 87 | public BufferedImage generateBufferedImage(List transitions, Format format, StyleAttribute... styles) throws RuntimeException { 88 | log.debug("Generating workflow image.."); 89 | Renderer renderer = createRenderer(transitions, format, styles); 90 | log.debug("Workflow image rendered to BufferedImage."); 91 | return renderer.toImage(); 92 | } 93 | 94 | private Renderer createRenderer(List transitions, Format format, StyleAttribute... styles) { 95 | // Generate image using Graphviz from dot format 96 | final guru.nidi.graphviz.engine.Format IMAGE_FORMAT = graphvizFormatFrom(format); 97 | log.debug("Using default image format: " + IMAGE_FORMAT); 98 | 99 | boolean useDotFormat = dotFormat != null; 100 | Graphviz.useEngine(List.of(new GraphvizJdkEngine())); // Use GraalJS as the default engine 101 | 102 | Graphviz gv; 103 | if (useDotFormat) { // Custom dot format don't need transitions 104 | log.debug("Using custom Dot format: " + System.lineSeparator() + dotFormat); 105 | gv = Graphviz.fromString(dotFormat); 106 | } else { 107 | if (transitions == null || transitions.isEmpty()) { 108 | throw new IllegalArgumentException("Transitions list can not be null or empty when dotFormat is null. Cannot generate image."); 109 | } 110 | gv = Graphviz.fromGraph(createGraph(transitions, computedTransitions, styles)); //JS engine is used by default 111 | } 112 | if (styles != null && styles.length > 0) { 113 | Graphviz finalGv = gv; 114 | gv = Arrays.stream(styles) 115 | .filter(style -> style == StyleGraph.SKETCHY) 116 | .findFirst() 117 | .map(style -> finalGv.processor(new Roughifyer(new JdkJavascriptEngine()) // Use Roughifyer for sketchy style 118 | .bowing(2) 119 | .curveStepCount(6) 120 | .roughness(1) 121 | .fillStyle(FillStyle.hachure().width(2).gap(5).angle(0)) 122 | .font("*serif", "Comic Sans MS"))) 123 | .orElse(gv); 124 | } 125 | return gv.render(IMAGE_FORMAT); 126 | } 127 | 128 | /** 129 | * Builder class for {@link GraphvizImageGenerator}. 130 | */ 131 | public static class GraphvizImageGeneratorBuilder { 132 | private String dotFormat; 133 | private List computedTransitions; 134 | 135 | /** 136 | * Sets the dot format for the graph image. 137 | * 138 | * @param dotFormat the dot format string 139 | * @return the current {@link GraphvizImageGeneratorBuilder} instance 140 | */ 141 | public GraphvizImageGeneratorBuilder dotFormat(String dotFormat) { 142 | this.dotFormat = dotFormat; 143 | return this; 144 | } 145 | 146 | /** 147 | * Sets the computed transitions for the graph image. 148 | * 149 | * @param computedTransitions the list of computed transitions 150 | * @return the current {@link GraphvizImageGeneratorBuilder} instance 151 | */ 152 | public GraphvizImageGeneratorBuilder computedTransitions(List computedTransitions) { 153 | this.computedTransitions = computedTransitions; 154 | return this; 155 | } 156 | 157 | /** 158 | * Builds and returns a new {@link GraphvizImageGenerator} instance. 159 | * 160 | * @return a new {@link GraphvizImageGenerator} instance 161 | */ 162 | public GraphvizImageGenerator build() { 163 | return new GraphvizImageGenerator(this); 164 | } 165 | } 166 | 167 | /** 168 | * Generates the default DOT format string from the given list of transitions. 169 | * This method is deprecated, and it is recommended to use the {@link #createGraph(List, List, StyleAttribute...)} method instead. 170 | * 171 | * @param transitions the list of transitions 172 | * @return the generated DOT format string 173 | * @deprecated Use {@link #createGraph(List, List, StyleAttribute...)} for generating the graph representation. 174 | */ 175 | @Deprecated 176 | private String defaultDotFormat(List transitions) { 177 | StringBuilder sb = new StringBuilder(); 178 | sb.append("digraph workflow {").append(System.lineSeparator()); 179 | sb.append(" ").append("node [style=filled,fillcolor=lightgrey]").append(System.lineSeparator()); 180 | sb.append(" ").append("rankdir=LR;").append(System.lineSeparator()); 181 | sb.append(" ").append("beautify=true").append(System.lineSeparator()); 182 | sb.append(System.lineSeparator()); 183 | for (Transition transition : transitions) { 184 | if (transition.to() instanceof Node) { 185 | sb.append(" ") // NodeFrom -> NodeTo 186 | .append(transition.from() instanceof Node ? 187 | sanitizeNodeName(((Node) transition.from()).getName()) : 188 | transition.from().toString().toLowerCase()) 189 | .append(" -> ") 190 | .append(sanitizeNodeName(((Node) transition.to()).getName())).append(";") 191 | .append(System.lineSeparator()); 192 | } else if (transition.to() == WorkflowStateName.END && transition.from() instanceof Node) { 193 | sb.append(" ") // NodeFrom -> END 194 | .append(sanitizeNodeName(((Node) transition.from()).getName())) 195 | .append(" -> ") 196 | .append(((WorkflowStateName) transition.to()).toString().toLowerCase()).append(";") 197 | .append(System.lineSeparator()) 198 | .append(System.lineSeparator()); 199 | } else { 200 | sb.append(" ") // NodeFrom -> NodeTo 201 | .append(sanitizeNodeName(transition.from().toString().toLowerCase())) 202 | .append(" -> ") 203 | .append(sanitizeNodeName(transition.to().toString().toLowerCase())).append(";") 204 | .append(System.lineSeparator()); 205 | } 206 | } 207 | sb.append(" ") 208 | .append(WorkflowStateName.START.toString().toLowerCase()+" [shape=Mdiamond, fillcolor=\"orange\"];") 209 | .append(System.lineSeparator()); 210 | sb.append(" ") 211 | .append(WorkflowStateName.END.toString().toLowerCase()+" [shape=Msquare, fillcolor=\"lightgreen\"];") 212 | .append(System.lineSeparator()); 213 | sb.append("}"); 214 | return sb.toString(); 215 | } 216 | 217 | /** 218 | * Creates a graph representation from the given list of transitions. 219 | * 220 | * @param transitions the list of transitions 221 | * @param styles optional styles to apply to the graph (e.g., orientation) 222 | * @return the generated graph representation 223 | */ 224 | private static Graph createGraph(List transitions, List computedTransitions, StyleAttribute... styles) { 225 | Rank.RankDir rankDir = Arrays.stream(styles) 226 | .filter(style -> style == HORIZONTAL) 227 | .findFirst() 228 | .map(style -> Rank.RankDir.LEFT_TO_RIGHT) 229 | .orElse(Rank.RankDir.TOP_TO_BOTTOM); 230 | 231 | boolean useComputedTransitions = computedTransitions != null && !computedTransitions.isEmpty(); 232 | onceComputedTransition.clear(); 233 | List nodes = transitions.stream() 234 | .map(transition -> { 235 | if (useComputedTransitions) { 236 | boolean wasExecuted = computedTransitions.stream() 237 | .anyMatch(computedTransition -> computedTransition.getTransition().equals(transition)); 238 | return createComputedGraphNode(transition, wasExecuted); 239 | } else { 240 | return createDefinitionGraphNode(transition); 241 | } 242 | }) 243 | .collect(Collectors.toList()); 244 | 245 | // Count the occurrences of each node name 246 | Map nodeNameCounts = nodes.stream() 247 | .collect(Collectors.groupingBy(guru.nidi.graphviz.model.Node::name, Collectors.counting())); 248 | 249 | // Style nodes that have multiple occurrences computed as executed 250 | List newNodes = nodes.stream() 251 | .map(node -> { 252 | if (nodeNameCounts.get(node.name()) > 1 && onceComputedTransition.keySet().stream() 253 | .anyMatch(transitionState -> sanitizeNodeName(transitionState.graphName()).equals(node.name().value()))) { 254 | return node.with(Style.ROUNDED, Color.rgb(40, 167, 70), Color.rgb(91, 154, 119).fill()); 255 | } 256 | return node; 257 | }) 258 | .collect(Collectors.toUnmodifiableList()); 259 | 260 | 261 | return graph("workflow").directed() 262 | .graphAttr().with(GraphAttr.splines(GraphAttr.SplineMode.POLYLINE), GraphAttr.CENTER, Rank.dir(rankDir)) 263 | .nodeAttr().with(Shape.RECTANGLE, Color.LIGHTBLUE2, Style.ROUNDED, Font.name("arial")) 264 | .linkAttr().with(Color.DARKGREEN, Style.SOLID, Arrow.NORMAL.size(0.8)) 265 | .with(newNodes); 266 | } 267 | 268 | /** 269 | * Creates a Graphviz node representation from the given transition. 270 | * 271 | * @param transition the transition to create the Graphviz node from 272 | * @return the created Graphviz node 273 | */ 274 | private static guru.nidi.graphviz.model.Node createComputedGraphNode (Transition transition, boolean wasExecuted) { 275 | if (transition.from() == WorkflowStateName.START) { 276 | return createDefinitionGraphNode(transition); 277 | } else if (transition.to() == WorkflowStateName.END) { 278 | Link linkToEnd = to(node(WorkflowStateName.END.graphName()) 279 | .with(Shape.M_SQUARE, Color.GREEN, Style.FILLED, Color.rgb(217, 255, 212).fill())); 280 | if (!wasExecuted) linkToEnd.add(Color.rgb(157, 165, 171)); 281 | return styleNodeOnce(transition.from(), wasExecuted) 282 | .link(linkToEnd); 283 | } else if (transition.from() instanceof Conditional) { 284 | return styleNodeOnce(transition.from(), wasExecuted) 285 | .link(createLinkStyled(transition.to(), wasExecuted).add(Style.DASHED)); 286 | } else { 287 | return styleNodeOnce(transition.from(), wasExecuted) 288 | .link(createLinkStyled(transition.to(), wasExecuted)); 289 | } 290 | } 291 | 292 | private static guru.nidi.graphviz.model.Node styleNodeOnce (TransitionState transitionState, Boolean wasExecuted) { 293 | if (onceComputedTransition.containsKey(transitionState) && onceComputedTransition.get(transitionState)) { 294 | return getGraphvizNodeFromNode(transitionState); 295 | } 296 | if (!onceComputedTransition.containsKey(transitionState) && wasExecuted) { 297 | onceComputedTransition.put(transitionState, true); 298 | return createNodeStyled(transitionState, true); 299 | } 300 | return createNodeStyled(transitionState, wasExecuted); 301 | } 302 | 303 | private static guru.nidi.graphviz.model.Node createNodeStyled (TransitionState transitionState, Boolean wasExecuted) { 304 | if (wasExecuted) { 305 | return getGraphvizNodeFromNode(transitionState) 306 | .with(Color.rgb(40, 167, 70), Color.rgb(91, 154, 119).fill()); 307 | } else { 308 | return getGraphvizNodeFromNode(transitionState) 309 | .with(Color.rgb(157, 165, 171), Style.combine(Style.ROUNDED, Style.DASHED), Color.rgb(108, 117, 125).fill()); 310 | } 311 | } 312 | 313 | private static Link createLinkStyled (TransitionState to, Boolean wasExecuted) { 314 | if (wasExecuted) { 315 | return to(getGraphvizNodeFromNode(to)); 316 | } else { 317 | return to(getGraphvizNodeFromNode(to)).with(Color.rgb(157, 165, 171)); 318 | } 319 | } 320 | 321 | private static guru.nidi.graphviz.model.Node createDefinitionGraphNode (Transition transition) { 322 | if (transition.from() == WorkflowStateName.START) { 323 | return node(WorkflowStateName.START.graphName()).with(Shape.M_DIAMOND, Color.ORANGE, Style.FILLED, Color.rgb(255, 240, 212).fill()) 324 | .link(getGraphvizNodeFromNode(transition.to())); 325 | } else if (transition.to() == WorkflowStateName.END) { 326 | return getGraphvizNodeFromNode(transition.from()) 327 | .link(node(WorkflowStateName.END.graphName()).with(Shape.M_SQUARE, Color.GREEN, Style.FILLED, Color.rgb(217, 255, 212).fill())); 328 | } else if (transition.from() instanceof Conditional) { 329 | return getGraphvizNodeFromNode(transition.from()) 330 | .link(to(getGraphvizNodeFromNode(transition.to())).with(Style.DASHED)); 331 | } else { 332 | return getGraphvizNodeFromNode(transition.from()) 333 | .link(getGraphvizNodeFromNode(transition.to())); 334 | } 335 | } 336 | 337 | private static guru.nidi.graphviz.model.Node getGraphvizNodeFromNode(TransitionState transitionState) { 338 | if (transitionState instanceof Node) { 339 | Node node = (Node) transitionState; 340 | List labels = node.labels(); 341 | String l = ""; 342 | if (labels != null && !labels.isEmpty()) { 343 | l = labels.stream().map(label -> ""+label.toLowerCase()+"
").collect(joining()); 344 | } 345 | Label label = Label.html(l.concat(sanitizeNodeName(node.graphName()))); 346 | if (node.hasLabel("Split") || node.hasLabel("Merge")) { 347 | return node(sanitizeNodeName(node.graphName())).with(label, Color.ORANGE); // style with labels 348 | } 349 | return node(sanitizeNodeName(node.graphName())).with(label); 350 | } else if (transitionState instanceof Conditional) { 351 | Conditional node = (Conditional) transitionState; 352 | Label label = Label.html(node.graphName()); 353 | return node(sanitizeNodeName(transitionState.graphName())).with(label,Shape.DIAMOND, Font.size(10)); 354 | } 355 | return node(sanitizeNodeName(transitionState.graphName())); 356 | } 357 | 358 | /** 359 | * Sanitizes the node name by removing special characters and converting it to Java naming convention. 360 | * 361 | * @param nodeName the node name to sanitize 362 | * @return the sanitized node name 363 | */ 364 | private static String sanitizeNodeName(String nodeName) { 365 | // Convert to camel case following Java naming convention 366 | return Arrays.stream(nodeName.replaceAll("[^a-zA-Z0-9 ]", "").split(" ")) 367 | .filter(word -> !word.isEmpty()) 368 | .map(word -> word.substring(0, 1).toUpperCase() + word.substring(1).toLowerCase()) 369 | .collect(joining()) 370 | .replaceFirst("^[A-Z]", nodeName.substring(0, 1).toLowerCase()); 371 | } 372 | 373 | private static guru.nidi.graphviz.engine.Format graphvizFormatFrom(Format format) { 374 | switch (format) { 375 | case SVG: 376 | return guru.nidi.graphviz.engine.Format.SVG; 377 | case PNG: 378 | return guru.nidi.graphviz.engine.Format.PNG; 379 | default: 380 | return guru.nidi.graphviz.engine.Format.SVG; 381 | } 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/graph/graphviz/Orientation.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.graph.graphviz; 2 | 3 | import io.github.czelabueno.jai.workflow.graph.StyleAttribute; 4 | 5 | /** 6 | * Enum representing the orientation of the graph in Graphviz. 7 | * It can be either vertical (top to bottom) or horizontal (left to right). 8 | */ 9 | public enum Orientation implements StyleAttribute { 10 | /** 11 | * Vertical orientation (top to bottom). 12 | */ 13 | VERTICAL("TB"), 14 | /** 15 | * Horizontal orientation (left to right). 16 | */ 17 | HORIZONTAL("LR"); 18 | 19 | private final String code; 20 | 21 | Orientation(String code) { 22 | this.code = code; 23 | } 24 | 25 | /** 26 | * Gets the code representing the orientation. 27 | * 28 | * @return the code representing the orientation 29 | */ 30 | public String getCode() { 31 | return code; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/graph/graphviz/StyleGraph.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.graph.graphviz; 2 | 3 | import io.github.czelabueno.jai.workflow.graph.StyleAttribute; 4 | 5 | /** 6 | * Enum representing the style of the graph in Graphviz. 7 | * It can be either default or sketchy. 8 | */ 9 | public enum StyleGraph implements StyleAttribute { 10 | /** 11 | * Default style. 12 | */ 13 | DEFAULT("default"), 14 | /** 15 | * Sketchy style. 16 | */ 17 | SKETCHY("sketchy"); 18 | 19 | private final String code; 20 | 21 | StyleGraph(String code) { 22 | this.code = code; 23 | } 24 | 25 | /** 26 | * Gets the code representing the style. 27 | * 28 | * @return the code representing the style 29 | */ 30 | @Override 31 | public String getCode() { 32 | return code; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/node/Conditional.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.node; 2 | 3 | import io.github.czelabueno.jai.workflow.transition.TransitionState; 4 | import lombok.Getter; 5 | import lombok.NonNull; 6 | 7 | import java.util.List; 8 | import java.util.Objects; 9 | import java.util.function.Function; 10 | 11 | /** 12 | * Represents a conditional node in a workflow that evaluates a condition function to transition a new jAI workflow state. 13 | *

14 | * Implements the {@link TransitionState} interface. 15 | * 16 | * @param the stateful bean POJO defined by the user. It is used to store the state of the workflow. 17 | */ 18 | public class Conditional implements TransitionState { 19 | 20 | private final String name; 21 | private Function> condition; 22 | @Getter 23 | private final List> expectedNodes; 24 | private T functionInput; 25 | private Node functionOutput; 26 | 27 | /** 28 | * Constructs a Conditional with the specified condition function and valid node types list. 29 | * 30 | * @param name the name of the conditional node 31 | * @param condition the condition function to evaluate 32 | * @param expectedNodes the list of nodes expected from the condition function 33 | * @throws NullPointerException if the condition function or expected node list is null 34 | * @throws IllegalArgumentException if the expected node list is empty or the condition function's return type is not valid 35 | */ 36 | public Conditional(String name, @NonNull Function> condition, @NonNull List> expectedNodes) { 37 | this.condition = Objects.requireNonNull(condition, "Condition function cannot be null"); 38 | this.expectedNodes = Objects.requireNonNull(expectedNodes, "The list of nodes expected from the condition function cannot be null"); 39 | if (expectedNodes.isEmpty()) { 40 | throw new IllegalArgumentException("The list of nodes expected from the condition function cannot be empty"); 41 | } 42 | this.name= name; 43 | } 44 | 45 | /** 46 | * Evaluates the condition function with the given stateful bean. 47 | * 48 | * @param input the stateful bean as input to the condition function 49 | * @return the resulting Node from the condition function 50 | * @throws NullPointerException if the input is null 51 | */ 52 | public Node evaluate(T input) { 53 | Objects.requireNonNull(input, "Function Input cannot be null"); 54 | functionInput = input; 55 | condition = condition.andThen(resultNode -> { 56 | if (!expectedNodes.contains(resultNode)) { 57 | throw new RuntimeException("The condition function returned an invalid node type. Expected one of: " + expectedNodes + " but got: " + resultNode.getName() + " instead."); 58 | } 59 | return resultNode; 60 | }); 61 | functionOutput = condition.apply(input); 62 | return functionOutput; 63 | } 64 | 65 | /** 66 | * Creates a new Conditional with the specified condition function. 67 | * 68 | * @param name the name of the conditional node 69 | * @param condition the condition function to evaluate 70 | * @param expectedNodes the list of nodes expected from the condition function 71 | * @param the stateful bean as input to the condition function 72 | * @return a new Conditional instance 73 | */ 74 | public static Conditional eval(String name, Function> condition, List> expectedNodes) { 75 | return new Conditional<>(name, condition, expectedNodes); 76 | } 77 | 78 | @Override 79 | public boolean equals(Object o) { 80 | if (this == o) return true; 81 | if (o == null || getClass() != o.getClass()) return false; 82 | 83 | Conditional that = (Conditional) o; 84 | 85 | return Objects.equals(condition, that.condition) && 86 | Objects.equals(expectedNodes, that.expectedNodes) && 87 | Objects.equals(name, that.name); 88 | } 89 | 90 | @Override 91 | public int hashCode() { 92 | int result = condition != null ? condition.hashCode() : 0; 93 | result = 31 * result + (expectedNodes != null ? expectedNodes.hashCode() : 0); 94 | result = 31 * result + (name != null ? name.hashCode() : 0); 95 | return result; 96 | } 97 | 98 | @Override 99 | public String toString() { 100 | return "Conditional{" + 101 | "name='" + name + 102 | ", condition=" + condition + 103 | ", validNodes=" + expectedNodes + 104 | '}'; 105 | } 106 | 107 | /** 108 | * Returns the name formatted for the graph. 109 | * 110 | * @return the name formatted for the graph. 111 | */ 112 | @Override 113 | public String graphName() { 114 | if (name != null) { 115 | return name.toLowerCase(); 116 | } 117 | return "Conditional".toLowerCase(); 118 | } 119 | 120 | /** 121 | * Returns the labels for {@link Conditional}. 122 | * 123 | * @return the labels for a Conditional Node. 124 | */ 125 | @Override 126 | public List labels() { 127 | return List.of("Conditional"); 128 | } 129 | 130 | @Override 131 | public Object input() { 132 | return functionInput; 133 | } 134 | 135 | @Override 136 | public Object output() { 137 | if (functionOutput == null) { 138 | return null; 139 | } 140 | return functionOutput.getName(); // Node name 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/node/Node.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.node; 2 | 3 | import io.github.czelabueno.jai.workflow.transition.TransitionState; 4 | import lombok.Getter; 5 | import lombok.NonNull; 6 | 7 | import java.util.ArrayList; 8 | import java.util.Arrays; 9 | import java.util.List; 10 | import java.util.Objects; 11 | import java.util.function.Function; 12 | 13 | import static java.util.Collections.emptyList; 14 | 15 | /** 16 | * Represents a Node in a workflow that executes a function with a given input and produces an output. 17 | *

18 | * A Node represents a single unit of work within the workflow. It encapsulates a specific function or task that processes the stateful bean and updates it. 19 | *

20 | *

21 | * This class implements the {@link TransitionState} interface. 22 | *

23 | * @param the type of the input to the function. Usually a stateful bean POJO defined by the user. 24 | * @param the type of the output from the function. Usually a stateful bean POJO defined by the user. 25 | */ 26 | public class Node implements TransitionState { 27 | 28 | @Getter 29 | private final String name; 30 | private final Function function; 31 | private List labels; 32 | private T functionInput; 33 | private R functionOutput; 34 | 35 | /** 36 | * Constructs a Node with the specified name and function. 37 | * 38 | * @param name the name of the node 39 | * @param function the function to execute 40 | * @throws IllegalArgumentException if the node name is empty 41 | * @throws NullPointerException if the name or function is null 42 | */ 43 | public Node(@NonNull String name, @NonNull Function function) { 44 | if (name.trim().isEmpty()) { 45 | throw new IllegalArgumentException("Node name cannot be empty"); 46 | } 47 | this.name = name; 48 | this.function = function; 49 | } 50 | 51 | /** 52 | * Adds the specified labels to the Node. 53 | * 54 | * @param labels the labels to add 55 | */ 56 | public void setLabels(String... labels) { 57 | if (this.labels == null) { 58 | this.labels = new ArrayList<>(Arrays.asList(labels)); 59 | } else { 60 | this.labels.addAll(Arrays.asList(labels)); 61 | } 62 | } 63 | 64 | /** 65 | * Gets the label for the Node. 66 | * 67 | * @param label the label to get 68 | * @return the label for the Node 69 | */ 70 | public String getLabel(String label) { 71 | if (labels == null) { 72 | return null; 73 | } 74 | return labels.stream() 75 | .filter(l -> l.equals(label)) 76 | .findFirst() 77 | .orElse(null); 78 | } 79 | 80 | /** 81 | * Checks if the Node has the specified label. 82 | * 83 | * @param label the label to check 84 | * @return true if the Node has the label, false otherwise 85 | */ 86 | public boolean hasLabel(String label) { 87 | if (labels == null) { 88 | return false; 89 | } 90 | return labels.contains(label); 91 | } 92 | 93 | /** 94 | * Executes the function with the given input and stores the input and output. 95 | * 96 | * @param input the input to the function 97 | * @return the output from the function 98 | * @throws IllegalArgumentException if the input is null 99 | */ 100 | public R execute(T input) { 101 | if (input == null) { 102 | throw new IllegalArgumentException("Function input cannot be null"); 103 | } 104 | functionInput = input; 105 | R output = function.apply(input); 106 | functionOutput = output; 107 | return output; 108 | } 109 | 110 | /** 111 | * Creates a new Node with the specified name and function. 112 | * 113 | * @param name the name of the node 114 | * @param function the function to execute 115 | * @param the type of the input to the function 116 | * @param the type of the output from the function 117 | * @return a new Node instance 118 | */ 119 | public static Node from(String name, Function function) { 120 | return new Node<>(name, function); 121 | } 122 | 123 | @Override 124 | public boolean equals(Object o) { 125 | if (this == o) return true; 126 | if (o == null || getClass() != o.getClass()) return false; 127 | 128 | Node node = (Node) o; 129 | 130 | if (!Objects.equals(name, node.name)) return false; 131 | return Objects.equals(function, node.function); 132 | } 133 | 134 | @Override 135 | public int hashCode() { 136 | int result = name != null ? name.hashCode() : 0; 137 | result = 31 * result + (function != null ? function.hashCode() : 0); 138 | return result; 139 | } 140 | 141 | @Override 142 | public String toString() { 143 | return "Node{" + 144 | "name='" + name + '\'' + 145 | ", function=" + function + 146 | '}'; 147 | } 148 | 149 | /** 150 | * Returns the name formatted for the graph. 151 | * 152 | * @return the name formatted for the graph. 153 | */ 154 | @Override 155 | public String graphName() { 156 | return name.toLowerCase(); 157 | } 158 | 159 | /** 160 | * Returns the labels for a Node. 161 | * 162 | * @return the labels for a Node. 163 | */ 164 | @Override 165 | public List labels() { 166 | return labels != null ? labels : emptyList(); 167 | } 168 | 169 | @Override 170 | public Object input() { 171 | return functionInput; 172 | } 173 | 174 | @Override 175 | public Object output() { 176 | return functionOutput; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/transition/ComputedTransition.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.transition; 2 | 3 | import lombok.Getter; 4 | import lombok.NonNull; 5 | 6 | import java.time.LocalDateTime; 7 | import java.util.UUID; 8 | 9 | /** 10 | * Represents a computed transition in a jai workflow. 11 | */ 12 | @Getter 13 | public class ComputedTransition{ 14 | private final UUID id; 15 | private final Integer order; 16 | private final Transition transition; 17 | private final LocalDateTime computedAt; 18 | private final Object payload; 19 | 20 | private ComputedTransition(@NonNull Integer order, @NonNull Transition transition) { 21 | if (transition.from() == null) { 22 | throw new RuntimeException("Transition node 'from' cannot be null"); 23 | } 24 | if (transition.to() == null) { 25 | throw new RuntimeException("Transition node 'to' cannot be null"); 26 | } 27 | if (order <= 0) { 28 | throw new RuntimeException("Transition order cannot be negative"); 29 | } 30 | this.id = UUID.randomUUID(); 31 | this.order = order; 32 | this.transition = transition; 33 | this.computedAt = LocalDateTime.now(); 34 | this.payload = transition.from().output(); 35 | } 36 | 37 | /** 38 | * Creates a new ComputedTransition with the specified order and transition. 39 | * 40 | * @param order the order of the transition 41 | * @param transition the transition to compute 42 | * @return a new ComputedTransition instance 43 | */ 44 | public static ComputedTransition from(@NonNull Integer order, @NonNull Transition transition) { 45 | return new ComputedTransition(order, transition); 46 | } 47 | 48 | @Override 49 | public boolean equals(Object o) { 50 | if (this == o) return true; 51 | if (o == null || getClass() != o.getClass()) return false; 52 | 53 | ComputedTransition that = (ComputedTransition) o; 54 | 55 | if (!id.equals(that.id)) return false; 56 | if (!order.equals(that.order)) return false; 57 | if (!transition.equals(that.transition)) return false; 58 | if (!computedAt.equals(that.computedAt)) return false; 59 | return payload.equals(that.payload); 60 | } 61 | 62 | @Override 63 | public int hashCode() { 64 | int result = id.hashCode(); 65 | result = 31 * result + order.hashCode(); 66 | result = 31 * result + transition.hashCode(); 67 | result = 31 * result + computedAt.hashCode(); 68 | result = 31 * result + (payload != null ? payload.hashCode() : 0); 69 | return result; 70 | } 71 | 72 | @Override 73 | public String toString() { 74 | return "ComputedTransition{" + 75 | "id=" + id + 76 | ", order=" + order + 77 | ", transition=" + transition + 78 | ", computedAt=" + computedAt + 79 | ", payload=" + payload + 80 | '}'; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/transition/Transition.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.transition; 2 | 3 | import io.github.czelabueno.jai.workflow.node.Node; 4 | import io.github.czelabueno.jai.workflow.WorkflowStateName; 5 | import lombok.NonNull; 6 | 7 | /** 8 | * Represents a transition between two states in a workflow. 9 | * The states can be instances of {@link Node} or {@link WorkflowStateName}. 10 | * 11 | * @param from the starting state of the transition, could be an instance of {@link Node}, {@link io.github.czelabueno.jai.workflow.node.Conditional} or {@link WorkflowStateName} 12 | * @param to the ending state of the transition, could be an instance of {@link Node}, {@link io.github.czelabueno.jai.workflow.node.Conditional} or {@link WorkflowStateName} 13 | */ 14 | public record Transition (TransitionState from, TransitionState to) { 15 | 16 | /** 17 | * Constructs a Transition with the specified from and to states. 18 | * 19 | * @param from the starting state of the transition, must be an instance of {@link Node} or {@link WorkflowStateName} 20 | * @param to the ending state of the transition, must be an instance of {@link Node} or {@link WorkflowStateName} 21 | * @throws IllegalArgumentException if the from state is {@link WorkflowStateName#END}, 22 | * if the to state is {@link WorkflowStateName#START}, 23 | * or if the transition is from {@link WorkflowStateName#START} to {@link WorkflowStateName#END} 24 | * @throws NullPointerException if the from or to state is null 25 | */ 26 | public Transition(@NonNull TransitionState from, @NonNull TransitionState to) { 27 | if (from == WorkflowStateName.END) { 28 | throw new IllegalArgumentException("Cannot transition from an END state"); 29 | } 30 | if (to == WorkflowStateName.START) { 31 | throw new IllegalArgumentException("Cannot transition to a START state"); 32 | } 33 | if (from == WorkflowStateName.START && to == WorkflowStateName.END) { 34 | throw new IllegalArgumentException("Cannot transition from START to END state"); 35 | } 36 | this.from = from; 37 | this.to = to; 38 | } 39 | 40 | /** 41 | * Creates a new Transition with the specified from and to states. 42 | * 43 | * @param from the starting state of the transition, must be an instance of {@link Node} or {@link WorkflowStateName} 44 | * @param to the ending state of the transition, must be an instance of {@link Node} or {@link WorkflowStateName} 45 | * @return a new Transition instance 46 | */ 47 | public static Transition from(TransitionState from, TransitionState to) { 48 | return new Transition(from, to); 49 | } 50 | 51 | @Override 52 | public boolean equals(Object o) { 53 | if (this == o) return true; 54 | if (o == null || getClass() != o.getClass()) return false; 55 | 56 | Transition that = (Transition) o; 57 | 58 | if (!from.equals(that.from)) return false; 59 | return to.equals(that.to); 60 | } 61 | 62 | @Override 63 | public int hashCode() { 64 | int result = from.hashCode(); 65 | result = 31 * result + to.hashCode(); 66 | return result; 67 | } 68 | 69 | /** 70 | * Returns a string representation of the transition. 71 | * 72 | * @return a string representation of the transition in the format "from -> to" 73 | */ 74 | @Override 75 | public String toString() { 76 | String transition = ""; 77 | if (from != null) transition = from.graphName() + " -> "; 78 | if (to != null) transition = transition + to.graphName(); 79 | return transition; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /jai-workflow-core/src/main/java/io/github/czelabueno/jai/workflow/transition/TransitionState.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.transition; 2 | 3 | import java.util.List; 4 | 5 | /** 6 | * Represents a component in the jAI workflow anatomy that can produce a transition and update the workflow state. 7 | *

8 | * For example, a {@link io.github.czelabueno.jai.workflow.node.Node} can produce a transition by executing a function. 9 | *

10 | *

11 | * Classes implementing this interface can be used to generate a state as part of a {@link Transition}. 12 | *

13 | */ 14 | public interface TransitionState{ 15 | 16 | /** 17 | * Returns the name of the state in the graph. 18 | * 19 | * @return the name of the state in the graph 20 | */ 21 | String graphName(); 22 | 23 | /** 24 | * Returns the labels of the state in the graph. 25 | * 26 | * @return the labels of the state in the graph 27 | */ 28 | List labels(); 29 | 30 | /** 31 | * Checks if the state has a given label. 32 | * 33 | * @param label the label to check 34 | * @return true if the state has the given label, false otherwise 35 | */ 36 | default boolean hasLabel(String label){ 37 | if (labels() == null) { 38 | return false; 39 | } 40 | return labels().contains(label); 41 | } 42 | /** 43 | * Returns the input of the state. 44 | * 45 | * @return the input of the state 46 | */ 47 | Object input(); 48 | 49 | /** 50 | * Returns the output of the state. 51 | * 52 | * @return the output of the state 53 | */ 54 | Object output(); 55 | } 56 | -------------------------------------------------------------------------------- /jai-workflow-core/src/test/java/io/github/czelabueno/jai/workflow/graph/graphviz/GraphvizImageGeneratorTest.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.graph.graphviz; 2 | 3 | import io.github.czelabueno.jai.workflow.graph.Format; 4 | import io.github.czelabueno.jai.workflow.node.Node; 5 | import io.github.czelabueno.jai.workflow.transition.ComputedTransition; 6 | import io.github.czelabueno.jai.workflow.transition.Transition; 7 | import lombok.SneakyThrows; 8 | import org.junit.jupiter.api.BeforeEach; 9 | import org.junit.jupiter.api.Test; 10 | 11 | import java.awt.image.BufferedImage; 12 | import java.nio.file.Files; 13 | import java.nio.file.Path; 14 | import java.nio.file.Paths; 15 | import java.util.Arrays; 16 | import java.util.Collections; 17 | import java.util.List; 18 | import java.util.stream.IntStream; 19 | 20 | import static io.github.czelabueno.jai.workflow.WorkflowStateName.END; 21 | import static io.github.czelabueno.jai.workflow.WorkflowStateName.START; 22 | import static org.assertj.core.api.Assertions.assertThat; 23 | import static org.assertj.core.api.Assertions.assertThatThrownBy; 24 | import static org.mockito.Mockito.mock; 25 | import static org.mockito.Mockito.when; 26 | 27 | class GraphvizImageGeneratorTest { 28 | 29 | GraphvizImageGenerator.GraphvizImageGeneratorBuilder builder; 30 | String dotFormat = "digraph { a -> b; }"; 31 | 32 | List computedTransitions; 33 | 34 | List transitions; 35 | 36 | @BeforeEach 37 | void setUp() { 38 | builder = GraphvizImageGenerator.builder(); 39 | 40 | // node mocked to simulate that the transition was computed 41 | Node a = mock(Node.class); 42 | Node b = mock(Node.class); 43 | Node c = mock(Node.class); 44 | when(a.output()).thenReturn("mockedPayloadOfNodeA"); 45 | when(b.output()).thenReturn("mockedPayloadOfNodeB"); 46 | when(c.output()).thenReturn("mockedPayloadOfNodeC"); 47 | when(a.graphName()).thenReturn("a"); 48 | when(b.graphName()).thenReturn("b"); 49 | when(c.graphName()).thenReturn("c"); 50 | 51 | transitions = Arrays.asList( 52 | Transition.from(START, a), 53 | Transition.from(a, b), 54 | Transition.from(b, c), 55 | Transition.from(c, END) 56 | ); 57 | 58 | computedTransitions = IntStream.range(0, transitions.size()) 59 | .mapToObj(i -> ComputedTransition.from(i + 1, transitions.get(i))) 60 | .toList(); 61 | } 62 | 63 | @Test 64 | void test_builder_and_doFormat() { 65 | // given 66 | assertThat(builder).isNotNull(); 67 | // when 68 | GraphvizImageGenerator generator = builder.dotFormat(dotFormat).build(); 69 | // then 70 | assertThat(generator).isNotNull(); 71 | } 72 | 73 | @Test 74 | void test_builder_and_computed_transitions() { 75 | // given 76 | assertThat(builder).isNotNull(); 77 | // when 78 | GraphvizImageGenerator generator = builder.computedTransitions(computedTransitions).build(); 79 | // then 80 | assertThat(generator).isNotNull(); 81 | } 82 | 83 | @Test 84 | void test_throw_illegalArgumentException_when_generate_image_with_invalid_transitions() { 85 | // given 86 | List transitions = Collections.EMPTY_LIST; 87 | // when 88 | GraphvizImageGenerator generator = builder.build(); // built without dotFormat 89 | // then 90 | assertThat(generator).isNotNull(); 91 | assertThatThrownBy(() -> generator.generateImage(transitions)) // empty transitions 92 | .isInstanceOf(IllegalArgumentException.class) 93 | .hasMessage("Transitions list can not be null or empty when dotFormat is null. Cannot generate image."); 94 | } 95 | 96 | @Test 97 | void test_throw_illegalArgumentException_when_generate_image_with_invalid_outputPath() { 98 | // given 99 | List transitions = Arrays.asList( 100 | Transition.from( 101 | Node.from("a", s -> s + "1"), 102 | Node.from("b", s -> s + "2") 103 | ) 104 | ); 105 | // when 106 | GraphvizImageGenerator generator = builder.build(); 107 | // then 108 | assertThat(generator).isNotNull(); 109 | assertThatThrownBy(() -> generator.generateImage(transitions, "")) // empty output path 110 | .isInstanceOf(IllegalArgumentException.class) 111 | .hasMessage("Output path can not be null or empty. Cannot generate image."); 112 | } 113 | 114 | @SneakyThrows 115 | @Test 116 | void test_generate_image_with_dotFormat_provided() { 117 | // given 118 | GraphvizImageGenerator generator = builder.dotFormat("digraph { a -> b -> c -> d; }").build(); 119 | // when 120 | assertThat(generator).isNotNull(); 121 | generator.generateImage(null); // transitions are not required when dotFormat is provided 122 | // then 123 | Path path = Paths.get("workflow-image.svg"); 124 | assertThat(Files.exists(path)).isTrue(); 125 | } 126 | 127 | @SneakyThrows 128 | @Test 129 | void test_generate_image_with_dotFormat_and_outputPath() { 130 | // given 131 | GraphvizImageGenerator generator = builder.dotFormat(dotFormat).build(); 132 | // when 133 | assertThat(generator).isNotNull(); 134 | String customOutputPath = "image/my-workflow-from-test.svg"; 135 | generator.generateImage(null, customOutputPath); 136 | // then 137 | Path path = Paths.get(customOutputPath); 138 | assertThat(Files.exists(path)).isTrue(); 139 | } 140 | 141 | @SneakyThrows 142 | @Test 143 | void test_generate_image_with_dotFormat_and_format_PNG_and_sketchy_style() { 144 | // given 145 | GraphvizImageGenerator generator = builder.dotFormat("digraph { a -> b -> c -> d; }").build(); 146 | // when 147 | assertThat(generator).isNotNull(); 148 | String strPath = "image/my-workflow-dot-styles-from-test.png"; 149 | generator.generateImage(null, strPath, Format.PNG, StyleGraph.SKETCHY); // transitions are not required when dotFormat is provided 150 | // then 151 | Path path = Paths.get(strPath); 152 | assertThat(Files.exists(path)).isTrue(); 153 | } 154 | 155 | @SneakyThrows 156 | @Test 157 | void test_generate_image_with_transitions() { 158 | // given 159 | List transitions = Arrays.asList( 160 | Transition.from( 161 | Node.from("a", s -> s + "1"), 162 | Node.from("b", s -> s + "2") 163 | ) 164 | ); 165 | GraphvizImageGenerator generator = builder.build(); 166 | // when 167 | assertThat(generator).isNotNull(); 168 | generator.generateImage(transitions); 169 | // then 170 | Path path = Paths.get("workflow-image.svg"); // default output path 171 | assertThat(Files.exists(path)).isTrue(); 172 | String content = String.join("\n", Files.readAllLines(path)); 173 | assertThat(content.trim()).startsWith(" transitions = Arrays.asList( 181 | Transition.from( 182 | Node.from("a", s -> s + "1"), 183 | Node.from("b", s -> s + "2") 184 | ) 185 | ); 186 | GraphvizImageGenerator generator = builder.build(); 187 | // when 188 | assertThat(generator).isNotNull(); 189 | String strPath = "image/my-workflow-from-test.svg"; 190 | generator.generateImage(transitions, strPath); 191 | // then 192 | Path path = Paths.get(strPath); 193 | assertThat(Files.exists(path)).isTrue(); // custom output path 194 | String content = String.join("\n", Files.readAllLines(path)); 195 | assertThat(content.trim()).startsWith(" transitions = Arrays.asList( 203 | Transition.from( 204 | Node.from("a", s -> s + "1"), 205 | Node.from("b", s -> s + "2") 206 | ) 207 | ); 208 | GraphvizImageGenerator generator = builder.build(); 209 | // when 210 | assertThat(generator).isNotNull(); 211 | String strPath = "image/my-workflow-from-test.png"; 212 | generator.generateImage(transitions, strPath, Format.PNG); 213 | // then 214 | Path path = Paths.get(strPath); 215 | assertThat(Files.exists(path)).isTrue(); // custom output path 216 | assertThat(Files.probeContentType(path)).startsWith("image/png"); // file content-type 217 | } 218 | 219 | @SneakyThrows 220 | @Test 221 | void test_generate_image_with_transitions_output_and_styles() { 222 | // given 223 | List transitions = Arrays.asList( 224 | Transition.from( 225 | Node.from("a", s -> s + "1"), 226 | Node.from("b", s -> s + "2") 227 | ) 228 | ); 229 | GraphvizImageGenerator generator = builder.build(); 230 | // when 231 | assertThat(generator).isNotNull(); 232 | String strPath = "image/my-workflow-from-test-sketchy.png"; 233 | generator.generateImage(transitions, strPath, Format.PNG, StyleGraph.SKETCHY, Orientation.HORIZONTAL); 234 | // then 235 | Path path = Paths.get(strPath); 236 | assertThat(Files.exists(path)).isTrue(); // custom output path 237 | } 238 | 239 | @SneakyThrows 240 | @Test 241 | void test_buffered_image_with_all_params() { 242 | // given 243 | List transitions = Arrays.asList( 244 | Transition.from( 245 | Node.from("a", s -> s + "1"), 246 | Node.from("b", s -> s + "2") 247 | ) 248 | ); 249 | GraphvizImageGenerator generator = builder.build(); 250 | // when 251 | assertThat(generator).isNotNull(); 252 | BufferedImage image = generator.generateBufferedImage( 253 | transitions, 254 | Format.SVG, 255 | StyleGraph.SKETCHY, // style attribute 256 | Orientation.HORIZONTAL); // style attribute 257 | // then 258 | assertThat(image).isNotNull(); 259 | assertThat(image.getType()).isEqualTo(BufferedImage.TYPE_INT_ARGB); 260 | assertThat(image.getWidth()).isGreaterThan(0); 261 | assertThat(image.getHeight()).isGreaterThan(0); 262 | } 263 | 264 | @SneakyThrows 265 | @Test 266 | void test_buffered_image_with_dotFormat_and_styles() { 267 | // given 268 | GraphvizImageGenerator generator = builder.dotFormat("digraph { a -> b -> c -> d; }").build(); 269 | // when 270 | assertThat(generator).isNotNull(); 271 | BufferedImage image = generator.generateBufferedImage(null, 272 | Format.SVG, 273 | StyleGraph.SKETCHY, // style attribute 274 | Orientation.HORIZONTAL); // style attribute 275 | // then 276 | assertThat(image).isNotNull(); 277 | assertThat(image.getType()).isEqualTo(BufferedImage.TYPE_INT_ARGB); 278 | assertThat(image.getWidth()).isGreaterThan(0); 279 | assertThat(image.getHeight()).isGreaterThan(0); 280 | } 281 | 282 | @SneakyThrows 283 | @Test 284 | void test_generate_image_with_computedTransitions_and_styles() { 285 | // given 286 | GraphvizImageGenerator generator = builder.computedTransitions(computedTransitions).build(); 287 | // when 288 | assertThat(generator).isNotNull(); 289 | String strPath = "image/my-workflow-from-test-computed-transitions.png"; 290 | generator.generateImage(transitions, 291 | strPath, 292 | Format.PNG, 293 | StyleGraph.SKETCHY, 294 | Orientation.HORIZONTAL); 295 | // then 296 | Path path = Paths.get(strPath); 297 | assertThat(Files.exists(path)).isTrue(); // custom output path 298 | } 299 | 300 | @SneakyThrows 301 | @Test 302 | void test_generate_image_with_computedTransitions_and_styles_and_format_SVG() { 303 | // given 304 | GraphvizImageGenerator generator = builder.computedTransitions(computedTransitions).build(); 305 | // when 306 | assertThat(generator).isNotNull(); 307 | String strPath = "image/my-workflow-from-test-computed-transitions.svg"; 308 | generator.generateImage(transitions, 309 | strPath, 310 | Format.SVG, 311 | Orientation.VERTICAL); 312 | // then 313 | Path path = Paths.get(strPath); 314 | assertThat(Files.exists(path)).isTrue(); // custom output path 315 | } 316 | 317 | @SneakyThrows 318 | @Test 319 | void test_generate_image_with_computedTransitions_and_styles_and_dotFormat() { 320 | // given 321 | GraphvizImageGenerator generator = builder 322 | .computedTransitions(computedTransitions) // will be ignored when dotFormat is provided 323 | .dotFormat("digraph { _start_ -> a -> b -> c -> d -> _end_; }") 324 | .build(); 325 | // when 326 | assertThat(generator).isNotNull(); 327 | String strPath = "image/my-workflow-from-test-dot-computed-transitions.svg"; 328 | generator.generateImage(null, 329 | strPath, 330 | Format.SVG, 331 | StyleGraph.SKETCHY, 332 | Orientation.HORIZONTAL); 333 | // then 334 | Path path = Paths.get(strPath); 335 | assertThat(Files.exists(path)).isTrue(); // custom output path 336 | } 337 | 338 | @SneakyThrows 339 | @Test 340 | void test_buffered_image_with_computedTransitions_and_styles() { 341 | // given 342 | GraphvizImageGenerator generator = builder.computedTransitions(computedTransitions).build(); 343 | // when 344 | assertThat(generator).isNotNull(); 345 | BufferedImage image = generator.generateBufferedImage(transitions, 346 | Format.SVG, 347 | StyleGraph.SKETCHY, 348 | Orientation.HORIZONTAL); 349 | // then 350 | assertThat(image).isNotNull(); 351 | assertThat(image.getType()).isEqualTo(BufferedImage.TYPE_INT_ARGB); 352 | assertThat(image.getWidth()).isGreaterThan(0); 353 | assertThat(image.getHeight()).isGreaterThan(0); 354 | } 355 | 356 | @SneakyThrows 357 | @Test 358 | void test_buffered_image_with_computedTransitions_and_styles_and_format_PNG() { 359 | // given 360 | GraphvizImageGenerator generator = builder.computedTransitions(computedTransitions).build(); 361 | // when 362 | assertThat(generator).isNotNull(); 363 | BufferedImage image = generator.generateBufferedImage(transitions, 364 | Format.PNG, 365 | Orientation.VERTICAL); 366 | // then 367 | assertThat(image).isNotNull(); 368 | assertThat(image.getType()).isEqualTo(BufferedImage.TYPE_INT_ARGB); 369 | assertThat(image.getWidth()).isGreaterThan(0); 370 | assertThat(image.getHeight()).isGreaterThan(0); 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /jai-workflow-core/src/test/java/io/github/czelabueno/jai/workflow/node/ConditionalTest.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.node; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.util.List; 7 | import java.util.function.Function; 8 | 9 | import static org.assertj.core.api.Assertions.*; 10 | 11 | class ConditionalTest { 12 | 13 | // validated 14 | Node node1; 15 | Node node2; 16 | 17 | @BeforeEach 18 | void setUp() { 19 | Function sumToString = num -> { 20 | num += 1; 21 | return num.toString(); 22 | }; 23 | Function subsToString = num -> { 24 | num -= 1; 25 | return num.toString(); 26 | }; 27 | node1 = Node.from("node1", sumToString); 28 | node2 = Node.from("node2", subsToString); 29 | } 30 | @Test 31 | void test_validate_constructor() { 32 | Function condition = s -> s.equals("sum") ? node1 : node2; 33 | Conditional conditional = new Conditional("condition", condition, List.of(node1,node2)); 34 | assertThat(conditional).isNotNull(); 35 | } 36 | 37 | @Test 38 | void test_null_condition_constructor() { 39 | // Condition null 40 | assertThatExceptionOfType(NullPointerException.class) 41 | .isThrownBy(() -> new Conditional("condition", null, List.of(node1))) 42 | .withMessage("Condition function cannot be null"); 43 | // Expected nodes null 44 | assertThatExceptionOfType(NullPointerException.class) 45 | .isThrownBy(() -> new Conditional("condition", s -> s, null)) 46 | .withMessage("The list of nodes expected from the condition function cannot be null"); 47 | } 48 | 49 | @Test 50 | void test_empty_expected_nodes_constructor() { 51 | assertThatExceptionOfType(IllegalArgumentException.class) 52 | .isThrownBy(() -> new Conditional("condition", s -> s, List.of())) 53 | .withMessage("The list of nodes expected from the condition function cannot be empty"); 54 | } 55 | 56 | @Test 57 | void test_evaluate_valid_input() { 58 | // given 59 | Function condition = s -> s.equals("sum") ? node1 : node2; 60 | 61 | // when 62 | Conditional conditional = new Conditional("condition", condition, List.of(node1, node2)); 63 | Node node = conditional.evaluate("sum"); 64 | 65 | // then 66 | assertThat(node).isNotNull(); 67 | assertThat(node).isEqualTo(node1); 68 | assertThat(node.getName()).isEqualTo("node1"); 69 | 70 | // when 71 | Node other = conditional.evaluate("other"); 72 | 73 | // then 74 | assertThat(other).isNotNull(); 75 | assertThat(other).isEqualTo(node2); 76 | assertThat(other.getName()).isEqualTo("node2"); 77 | } 78 | 79 | @Test 80 | void test_evaluate_null_input() { 81 | Function condition = s -> s.equals("subs") ? node2 : node1; 82 | 83 | Conditional conditional = new Conditional("condition", condition, List.of(node2, node1)); 84 | assertThatExceptionOfType(NullPointerException.class) 85 | .isThrownBy(() -> conditional.evaluate(null)) 86 | .withMessage("Function Input cannot be null"); 87 | } 88 | 89 | @Test 90 | void test_inline_eval_static_method() { 91 | // given 92 | Node nodeReturned = Conditional.eval( 93 | "condition", 94 | s -> s > 5 ? node2 : node1, // function input type expected is the same node input type 95 | List.of(node2, node1) 96 | ).evaluate(6); 97 | 98 | // then 99 | assertThat(nodeReturned).isNotNull(); 100 | assertThat(nodeReturned).isEqualTo(node2); 101 | assertThat(nodeReturned.getName()).isEqualTo("node2"); 102 | } 103 | 104 | @Test 105 | void test_ignore_generic_type_in_eval_static_method() { 106 | // given 107 | Function condition = s -> s.equals("subs") ? node2 : node1; // function without generic type 108 | 109 | Conditional conditional = Conditional.eval("condition", 110 | condition, 111 | List.of(node2, node1)); 112 | Node node = conditional.evaluate("sum"); 113 | 114 | // then 115 | assertThat(node).isNotNull(); 116 | assertThat(node).isEqualTo(node1); 117 | assertThat(node.getName()).isEqualTo("node1"); 118 | 119 | // when 120 | Node other = conditional.evaluate("subs"); 121 | 122 | // then 123 | assertThat(other).isNotNull(); 124 | assertThat(other).isEqualTo(node2); 125 | assertThat(other.getName()).isEqualTo("node2"); 126 | } 127 | 128 | @Test 129 | void test_any_expected_nodes_does_not_match_with_returned_node() { 130 | assertThatExceptionOfType(RuntimeException.class) 131 | .isThrownBy(() -> Conditional.eval( 132 | "condition", 133 | s -> s > 5 ? node2 : node1, 134 | List.of(node1) // node2 is expected 135 | ) 136 | .evaluate(6)) 137 | .withMessageStartingWith("The condition function returned an invalid node type") 138 | .withMessageContaining("node1") 139 | .withMessageEndingWith("but got: node2 instead."); 140 | } 141 | 142 | @Test 143 | void test_conditional_get_graph_name() { 144 | Function condition = s -> node1; 145 | Conditional conditional = new Conditional("CONDITION", condition, List.of(node1)); 146 | assertThat(conditional.graphName()).isEqualTo("condition"); // lower case expected 147 | } 148 | 149 | @Test 150 | void test_conditional_has_labels() { 151 | Function condition = s -> node1; 152 | Conditional conditional = new Conditional("condition", condition, List.of(node1)); 153 | assertThat(conditional.hasLabel("label")).isFalse(); 154 | assertThat(conditional.hasLabel("Conditional")).isTrue(); 155 | assertThat(conditional.labels()).anySatisfy(label -> assertThat(label).isEqualTo("Conditional")); 156 | } 157 | 158 | @Test 159 | void test_conditional_input_and_output_values() { 160 | Function condition = s -> node1; 161 | Conditional conditional = new Conditional("condition", condition, List.of(node1)); 162 | assertThat(conditional.input()).isNull(); // null because the .evaluate() method is not called 163 | assertThat(conditional.output()).isNull(); 164 | 165 | conditional.evaluate("input"); 166 | assertThat(conditional.input()).isEqualTo("input"); 167 | assertThat(conditional.output()).isEqualTo(node1.getName()); // output value contains the node name only 168 | } 169 | 170 | @Test 171 | void test_conditional_input_and_output_types_and_values() { 172 | Function condition = s -> s > 5 ? node2 : node1; 173 | Conditional conditional = new Conditional("condition", condition, List.of(node1, node2)); 174 | conditional.evaluate(6); 175 | 176 | // Check input type and value 177 | assertThat(conditional.input()).isInstanceOf(Integer.class); 178 | assertThat(conditional.input()).isEqualTo(6); 179 | // Check output type and value 180 | assertThat(conditional.output()).isInstanceOf(String.class); 181 | assertThat(conditional.output()).isEqualTo(node2.getName()); 182 | } 183 | 184 | @Test 185 | void test_equals_and_hash() { 186 | Function condition1 = s -> node1; 187 | Function condition2 = s -> node2; 188 | Conditional conditional1 = new Conditional("condition", condition1, List.of(node1)); 189 | Conditional conditional2 = new Conditional("condition", condition1, List.of(node1)); 190 | Conditional conditional3 = new Conditional("condition2", condition2, List.of(node2)); 191 | assertThat(conditional1) 192 | .isEqualTo(conditional1) 193 | .isNotEqualTo(null) 194 | .isNotEqualTo(new Object()) 195 | .isEqualTo(conditional2) 196 | .isNotEqualTo(conditional3) 197 | .hasSameHashCodeAs(conditional2); 198 | } 199 | 200 | @Test 201 | void test_toString() { 202 | Function condition = s -> node1; 203 | Conditional conditional = new Conditional("condition", condition, List.of(node1)); 204 | assertThat(conditional.toString()).isEqualTo("Conditional{name='condition, condition=" + condition + ", validNodes=" + List.of(node1) + "}"); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /jai-workflow-core/src/test/java/io/github/czelabueno/jai/workflow/node/NodeTest.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.node; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.util.function.Function; 6 | 7 | import static org.assertj.core.api.Assertions.assertThat; 8 | import static org.junit.jupiter.api.Assertions.assertThrows; 9 | 10 | class NodeTest { 11 | 12 | // validated 13 | @Test 14 | void test_valid_constructor() { 15 | Node node = Node.from("node1", (String s) -> s + "1"); 16 | assertThat(node.getName()).isEqualTo("node1"); 17 | } 18 | 19 | @Test 20 | void test_null_node_name_constructor() { 21 | NullPointerException ilegal = assertThrows(NullPointerException.class, () -> Node.from(null, (String s) -> s + "1")); 22 | assertThat(ilegal.getMessage()).isEqualTo("name is marked non-null but is null"); 23 | } 24 | 25 | @Test 26 | void test_illegal_empty_node_name() { 27 | IllegalArgumentException ilegal = assertThrows(IllegalArgumentException.class, () -> Node.from("", (String s) -> s + "1")); 28 | assertThat(ilegal.getMessage()).isEqualTo("Node name cannot be empty"); 29 | } 30 | 31 | @Test 32 | void test_null_node_function_constructor() { 33 | NullPointerException ilegal = assertThrows(NullPointerException.class, () -> Node.from("node", null)); 34 | assertThat(ilegal.getMessage()).isEqualTo("function is marked non-null but is null"); 35 | } 36 | 37 | @Test 38 | void test_execute_valid_input() { 39 | Node node = Node.from("node1", (String s) -> s + "1"); 40 | assertThat(node.getName()).isEqualTo("node1"); 41 | assertThat(node.execute("test")).isEqualTo("test1"); 42 | assertThat(node.input()).isEqualTo("test"); 43 | } 44 | 45 | @Test 46 | void test_execute_function() { 47 | Function sumToString = num -> { 48 | num += 1; 49 | return num.toString(); 50 | }; 51 | Node node = Node.from("node1", sumToString); 52 | assertThat(node.execute(1)).isEqualTo("2"); 53 | assertThat(node.input()).isEqualTo(1); 54 | assertThat(node.output()).isEqualTo("2"); 55 | } 56 | 57 | @Test 58 | void test_execute_null_input() { 59 | Node node = Node.from("node1", (String s) -> s + "1"); 60 | IllegalArgumentException ilegal = assertThrows(IllegalArgumentException.class, () -> node.execute(null)); 61 | assertThat(ilegal.getMessage()).isEqualTo("Function input cannot be null"); 62 | } 63 | 64 | @Test 65 | void test_node_get_name() { 66 | Node node = Node.from("node1", (String s) -> s + "1"); 67 | assertThat(node.getName()).isEqualTo("node1"); 68 | } 69 | 70 | @Test 71 | void test_node_get_graph_name() { 72 | Node node = Node.from("NODE1", (String s) -> s + "1"); 73 | assertThat(node.graphName()).isEqualTo("node1"); // graph name is lower case 74 | } 75 | 76 | @Test 77 | void test_node_has_label() { 78 | Node node = Node.from("node1", (String s) -> s + "1"); 79 | assertThat(node.hasLabel("label")).isFalse(); 80 | 81 | node.setLabels("label"); 82 | assertThat(node.hasLabel("label")).isTrue(); 83 | assertThat(node.getLabel("label")).isEqualTo("label"); 84 | } 85 | 86 | @Test 87 | void test_node_function_input_and_output_values() { 88 | Node node = Node.from("node1", s -> s + "1"); 89 | node.execute("test"); 90 | assertThat(node.input()).isEqualTo("test"); 91 | assertThat(node.output()).isEqualTo("test1"); 92 | } 93 | 94 | @Test 95 | void test_node_function_input_and_output_types_and_values() { 96 | Function sumToString = num -> { 97 | num += 1; 98 | return num.toString(); 99 | }; 100 | Node node = Node.from("node1", sumToString); 101 | node.execute(1); 102 | 103 | // Check input type and value 104 | assertThat(node.input()).isInstanceOf(Integer.class); 105 | assertThat(node.input()).isEqualTo(1); 106 | // Check output type and value 107 | assertThat(node.output()).isInstanceOf(String.class); 108 | assertThat(node.output()).isEqualTo("2"); 109 | } 110 | 111 | @Test 112 | void test_equals_and_hash() { 113 | Function function = (String s) -> s + "1"; 114 | Node node1 = Node.from("node1", function); 115 | Node node2 = Node.from("node1", function); 116 | 117 | assertThat(node1) 118 | .isEqualTo(node1) 119 | .isNotEqualTo(null) 120 | .isNotEqualTo(new Object()) 121 | .isEqualTo(node2) 122 | .hasSameHashCodeAs(node2); 123 | 124 | assertThat(Node.from("node1", (String s) -> s + "1")) // other function 125 | .isNotEqualTo(node1); 126 | } 127 | 128 | @Test 129 | void test_hashCode() { 130 | Function function = (String s) -> s + "1"; 131 | Node node1 = Node.from("node1", function); 132 | Node node2 = Node.from("node1", function); 133 | 134 | assertThat(node1.hashCode()).isEqualTo(node2.hashCode()); 135 | } 136 | 137 | @Test 138 | void test_toString() { 139 | Function function = (String s) -> s + "1"; 140 | Node node = Node.from("node1", function); 141 | 142 | assertThat(node.toString()).isEqualTo("Node{name='node1', function=" + function + "}"); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /jai-workflow-core/src/test/java/io/github/czelabueno/jai/workflow/transition/ComputedTransitionTest.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.transition; 2 | 3 | import io.github.czelabueno.jai.workflow.node.Node; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.time.LocalDateTime; 7 | 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | import static org.assertj.core.api.Assertions.assertThatExceptionOfType; 10 | import static org.mockito.Mockito.mock; 11 | import static org.mockito.Mockito.when; 12 | 13 | class ComputedTransitionTest { 14 | 15 | // validated 16 | @Test 17 | void should_build_computed_transition_using_from() { 18 | // given 19 | // node mocked to simulate that the transition was computed 20 | Node mockFromNode = mock(Node.class); 21 | Node mockToNode = mock(Node.class); 22 | when(mockFromNode.output()).thenReturn("mockedPayloadOfFromNode"); 23 | when(mockFromNode.graphName()).thenReturn("from"); 24 | when(mockToNode.graphName()).thenReturn("to"); 25 | 26 | ComputedTransition computedTransition = ComputedTransition.from( 27 | 1, 28 | Transition.from(mockFromNode, mockToNode) 29 | ); 30 | 31 | // then 32 | assertThat(computedTransition.getId()).isNotNull(); 33 | assertThat(computedTransition.getOrder()).isEqualTo(1); 34 | assertThat(computedTransition.getTransition().from().graphName()).isEqualTo("from"); 35 | assertThat(computedTransition.getTransition().to().graphName()).isEqualTo("to"); 36 | assertThat(computedTransition.getComputedAt()).isBefore(LocalDateTime.now()); 37 | assertThat(computedTransition.getPayload()).isEqualTo("mockedPayloadOfFromNode"); 38 | } 39 | 40 | @Test 41 | void should_throw_exception_when_build_order_arg_is_null() { 42 | // then 43 | assertThatExceptionOfType(NullPointerException.class) 44 | .isThrownBy(() -> ComputedTransition.from( 45 | null, 46 | null 47 | )) 48 | .withMessage("order is marked non-null but is null"); 49 | } 50 | 51 | @Test 52 | void should_throw_exception_when_build_transition_arg_is_null() { 53 | // then 54 | assertThatExceptionOfType(NullPointerException.class) 55 | .isThrownBy(() -> ComputedTransition.from( 56 | 1, 57 | null 58 | )) 59 | .withMessage("transition is marked non-null but is null"); 60 | } 61 | 62 | @Test 63 | void should_throw_exception_when_transition_from_is_null() { 64 | // given 65 | Transition mockTransition = mock(Transition.class); 66 | when(mockTransition.from()).thenReturn(null); 67 | 68 | // then 69 | assertThatExceptionOfType(RuntimeException.class) 70 | .isThrownBy(() -> ComputedTransition.from( 71 | 1, 72 | mockTransition 73 | )) 74 | .withMessage("Transition node 'from' cannot be null"); 75 | } 76 | 77 | @Test 78 | void should_throw_exception_when_transition_to_is_null() { 79 | // given 80 | Node mockFromNode = mock(Node.class); 81 | Transition mockTransition = mock(Transition.class); 82 | when(mockTransition.from()).thenReturn(mockFromNode); 83 | when(mockTransition.to()).thenReturn(null); 84 | 85 | // then 86 | assertThatExceptionOfType(RuntimeException.class) 87 | .isThrownBy(() -> ComputedTransition.from( 88 | 1, 89 | mockTransition 90 | )) 91 | .withMessage("Transition node 'to' cannot be null"); 92 | } 93 | 94 | @Test 95 | void should_throw_exception_when_transition_order_is_negative() { 96 | // given 97 | Node mockFromNode = mock(Node.class); 98 | Node mockToNode = mock(Node.class); 99 | Transition mockTransition = mock(Transition.class); 100 | when(mockTransition.from()).thenReturn(mockFromNode); 101 | when(mockTransition.to()).thenReturn(mockToNode); 102 | 103 | // then 104 | assertThatExceptionOfType(RuntimeException.class) 105 | .isThrownBy(() -> ComputedTransition.from( 106 | -1, 107 | mockTransition 108 | )) 109 | .withMessage("Transition order cannot be negative"); 110 | } 111 | 112 | @Test 113 | void should_throw_exception_when_transition_order_is_zero() { 114 | // given 115 | Node mockFromNode = mock(Node.class); 116 | Node mockToNode = mock(Node.class); 117 | Transition mockTransition = mock(Transition.class); 118 | when(mockTransition.from()).thenReturn(mockFromNode); 119 | when(mockTransition.to()).thenReturn(mockToNode); 120 | 121 | // then 122 | assertThatExceptionOfType(RuntimeException.class) 123 | .isThrownBy(() -> ComputedTransition.from( 124 | 0, 125 | mockTransition 126 | )) 127 | .withMessage("Transition order cannot be negative"); 128 | } 129 | 130 | @Test 131 | void test_equals_and_hash() { 132 | // given 133 | Node mockFromNode = mock(Node.class); 134 | Node mockToNode = mock(Node.class); 135 | Transition transition = Transition.from(mockFromNode, mockToNode); 136 | ComputedTransition computedTransition1 = ComputedTransition.from(1, transition); 137 | ComputedTransition computedTransition2 = ComputedTransition.from(1, transition); 138 | 139 | // then 140 | // Each instance has a different id. They can never be the same. 141 | assertThat(computedTransition1) 142 | .isEqualTo(computedTransition1) 143 | .isNotEqualTo(null) 144 | .isNotEqualTo(new Object()) 145 | .isNotEqualTo(computedTransition2) 146 | .doesNotHaveSameHashCodeAs(computedTransition2); 147 | } 148 | 149 | @Test 150 | void test_toString() { 151 | // given 152 | Node mockFromNode = mock(Node.class); 153 | Node mockToNode = mock(Node.class); 154 | Transition transition = Transition.from(mockFromNode, mockToNode); 155 | ComputedTransition computedTransition = ComputedTransition.from(1, transition); 156 | 157 | // then 158 | assertThat(computedTransition.toString()).isEqualTo("ComputedTransition{id=" + computedTransition.getId() + ", order=1, transition=" + transition + ", computedAt=" + computedTransition.getComputedAt() + ", payload=" + computedTransition.getPayload() + "}"); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /jai-workflow-core/src/test/java/io/github/czelabueno/jai/workflow/transition/TransitionTest.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.transition; 2 | 3 | import io.github.czelabueno.jai.workflow.WorkflowStateName; 4 | import io.github.czelabueno.jai.workflow.node.Node; 5 | import org.junit.jupiter.api.Test; 6 | 7 | import static org.assertj.core.api.Assertions.assertThat; 8 | import static org.assertj.core.api.Assertions.assertThatExceptionOfType; 9 | 10 | class TransitionTest { 11 | 12 | // validated 13 | @Test 14 | void should_build_transition_using_from() { 15 | Transition transition = Transition.from( 16 | Node.from("from", s -> s + "1"), 17 | Node.from("to", s -> s + "2") 18 | ); 19 | 20 | assertThat(((Node)transition.from()).getName()).isEqualTo("from"); 21 | assertThat(((Node)transition.to()).getName()).isEqualTo("to"); 22 | 23 | assertThat(transition).hasToString("from -> to"); 24 | } 25 | // Transition Node to Node 26 | @Test 27 | void should_build_transition_using_nodes() { 28 | // given 29 | Node from = new Node("node1", s -> s + "1"); 30 | Node to = new Node("node2", s -> s + "2"); 31 | // when 32 | Transition transition = Transition.from(from, to); 33 | // then 34 | assertThat(transition.from()).isEqualTo(from); 35 | assertThat(transition.to()).isEqualTo(to); 36 | 37 | assertThat(transition).hasToString("node1 -> node2"); 38 | } 39 | // Transition Node to WorkflowState 40 | @Test 41 | void should_build_transition_using_node_and_workflowState() { 42 | // given 43 | Node from = new Node("node1", s -> s + "1"); 44 | WorkflowStateName to = WorkflowStateName.END; 45 | // when 46 | Transition transition = Transition.from(from, to); 47 | // then 48 | assertThat(transition.from()).isEqualTo(from); 49 | assertThat(transition.to()).isEqualTo(to); 50 | 51 | assertThat(transition).hasToString("node1 -> _end_"); 52 | } 53 | // Transition WorkflowState to Node 54 | @Test 55 | void should_build_transition_using_workflowState_and_node() { 56 | // given 57 | WorkflowStateName from = WorkflowStateName.START; 58 | Node to = new Node("node2", s -> s + "2"); 59 | // when 60 | Transition transition = Transition.from(from, to); 61 | // then 62 | assertThat(transition.from()).isEqualTo(from); 63 | assertThat(transition.to()).isEqualTo(to); 64 | 65 | assertThat(transition).hasToString("_start_ -> node2"); 66 | } 67 | 68 | @Test 69 | void should_throw_illegalArgumentException_when_transition_from_END() { 70 | assertThatExceptionOfType(IllegalArgumentException.class) 71 | .isThrownBy(() -> Transition.from(WorkflowStateName.END, Node.from("to", s -> s + "1"))) 72 | .withMessage("Cannot transition from an END state"); 73 | } 74 | 75 | @Test 76 | void should_throw_illegalArgumentException_when_transition_to_START() { 77 | assertThatExceptionOfType(IllegalArgumentException.class) 78 | .isThrownBy(() -> Transition.from(Node.from("from", s -> s + "2"), WorkflowStateName.START)) 79 | .withMessage("Cannot transition to a START state"); 80 | } 81 | 82 | @Test 83 | void should_throw_illegalArgumentException_when_transition_from_START_to_END() { 84 | assertThatExceptionOfType(IllegalArgumentException.class) 85 | .isThrownBy(() -> Transition.from(WorkflowStateName.START, WorkflowStateName.END)) 86 | .withMessage("Cannot transition from START to END state"); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /jai-workflow-core/src/test/resources/tinylog.properties: -------------------------------------------------------------------------------- 1 | writer.level = debug 2 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/pom.xml: -------------------------------------------------------------------------------- 1 | 3 | 4.0.0 4 | 5 | io.github.czelabueno 6 | jai-workflow-parent 7 | 0.3.0-SNAPSHOT 8 | ../pom.xml 9 | 10 | 11 | jai-workflow-langchain4j 12 | JavAI Workflow :: LangChain4j 13 | jAI Workflow + LangChain4j: Offers a comprehensive toolset for building agentic AI applications 14 | 15 | 16 | 1.0.0-alpha1 17 | 18 | 19 | 20 | 21 | io.github.czelabueno 22 | jai-workflow-core 23 | ${project.version} 24 | 25 | 26 | 27 | dev.langchain4j 28 | langchain4j 29 | ${langchain4j.version} 30 | 31 | 32 | 33 | dev.langchain4j 34 | langchain4j-reactor 35 | ${langchain4j.version} 36 | 37 | 38 | 39 | dev.langchain4j 40 | langchain4j-document-transformer-jsoup 41 | ${langchain4j.version} 42 | 43 | 44 | 45 | org.slf4j 46 | slf4j-api 47 | 48 | 49 | 50 | org.projectlombok 51 | lombok 52 | 53 | 54 | 55 | 56 | org.junit.jupiter 57 | junit-jupiter-engine 58 | ${junit.version} 59 | test 60 | 61 | 62 | 63 | org.mockito 64 | mockito-junit-jupiter 65 | test 66 | 67 | 68 | 69 | io.projectreactor 70 | reactor-test 71 | 3.7.0 72 | test 73 | 74 | 75 | 76 | org.assertj 77 | assertj-core 78 | ${assertj.version} 79 | test 80 | 81 | 82 | 83 | dev.langchain4j 84 | langchain4j-mistral-ai 85 | ${langchain4j.version} 86 | test 87 | 88 | 89 | 90 | org.tinylog 91 | tinylog-impl 92 | test 93 | 94 | 95 | 96 | org.tinylog 97 | slf4j-tinylog 98 | test 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/main/java/io/github/czelabueno/jai/workflow/langchain4j/AbstractStatefulBean.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.langchain4j; 2 | 3 | import lombok.Data; 4 | import reactor.core.publisher.Flux; 5 | 6 | /** 7 | * AbstractStatefulBean is an abstract class that represents a stateful bean which is responsible for holding the state of the workflow. 8 | * The state is a combination of a question, input data, output data and a response generation. 9 | * Every execution of the workflow initiates a state, which is then transferred among the nodes during their execution. 10 | * 11 | * Here is the simplest example of a stateful bean: 12 | *
{@code
13 |  * public class MyStatefulBean extends AbstractStatefulBean {
14 |  *     private List documents;
15 |  *     // other additional input/output fields that you want to store
16 |  * }
17 |  * }
18 | */ 19 | @Data 20 | public abstract class AbstractStatefulBean { 21 | 22 | private String question; 23 | private String generation; 24 | private Flux generationStream; 25 | } 26 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/main/java/io/github/czelabueno/jai/workflow/langchain4j/JAiWorkflow.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.langchain4j; 2 | 3 | import dev.langchain4j.data.message.AiMessage; 4 | import dev.langchain4j.data.message.UserMessage; 5 | import io.github.czelabueno.jai.workflow.StateWorkflow; 6 | import reactor.core.publisher.Flux; 7 | 8 | import java.awt.image.BufferedImage; 9 | import java.io.IOException; 10 | import java.nio.file.Path; 11 | 12 | import static dev.langchain4j.internal.ValidationUtils.ensureNotNull; 13 | 14 | /** 15 | * The {@link JAiWorkflow} interface defines the entry-point contract for a workflow that processes user messages 16 | * and generates AI responses. It provides basic methods for synchronous and asynchronous (streaming) responses. 17 | */ 18 | public interface JAiWorkflow extends StateWorkflow { 19 | 20 | /** 21 | * Generates an AI response to the given question. 22 | * This method ensures that the question is not null before processing. 23 | * 24 | * @param question the question to be answered 25 | * @return the AI response as a string 26 | * @throws IllegalArgumentException if the question is null 27 | */ 28 | default String answer(String question){ 29 | ensureNotNull(question, "question"); 30 | return answer(new UserMessage(question)).text(); 31 | } 32 | 33 | /** 34 | * Generates an AI response to the given user message. 35 | * 36 | * @param question the UserMessage containing the question 37 | * @return the AI response as an AiMessage 38 | */ 39 | AiMessage answer(UserMessage question); 40 | 41 | /** 42 | * Generates a streaming AI response to the given question. 43 | * This method ensures that the question is not null before processing. 44 | * 45 | * @param question the question to be answered 46 | * @return a Flux stream of the AI response tokens 47 | * @throws IllegalArgumentException if the question is null 48 | */ 49 | default Flux answerStream(String question){ 50 | ensureNotNull(question, "question"); 51 | return answerStream(new UserMessage(question)); 52 | } 53 | 54 | /** 55 | * Generates a streaming AI response to the given user message. 56 | * 57 | * @param question the UserMessage containing the question 58 | * @return a Flux stream of the AI response tokens 59 | */ 60 | Flux answerStream(UserMessage question); 61 | 62 | /** 63 | * Generates a workflow image of the current workflow state. 64 | * This method ensures that the workflow image output path is not null before processing. 65 | * 66 | * @param workflowImageOutputPath the path to save the workflow image 67 | * @return the workflow image as a {@link JAiWorkflow} 68 | * @throws IOException if an I/O error occurs 69 | * @throws IllegalArgumentException if the workflow image output path is null 70 | */ 71 | JAiWorkflow getWorkflowImage(Path workflowImageOutputPath) throws IOException; 72 | 73 | /** 74 | * Gets the workflow image as a {@link BufferedImage}. 75 | * 76 | * @return the workflow image as a {@link BufferedImage} 77 | */ 78 | BufferedImage getWorkflowImage(); 79 | } 80 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/main/java/io/github/czelabueno/jai/workflow/langchain4j/internal/DefaultJAiWorkflow.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.langchain4j.internal; 2 | 3 | import io.github.czelabueno.jai.workflow.DefaultStateWorkflow; 4 | import io.github.czelabueno.jai.workflow.StateWorkflow; 5 | import io.github.czelabueno.jai.workflow.langchain4j.AbstractStatefulBean; 6 | import io.github.czelabueno.jai.workflow.langchain4j.JAiWorkflow; 7 | import io.github.czelabueno.jai.workflow.langchain4j.node.StreamingNode; 8 | import io.github.czelabueno.jai.workflow.node.Node; 9 | import dev.langchain4j.data.message.AiMessage; 10 | import dev.langchain4j.data.message.UserMessage; 11 | import io.github.czelabueno.jai.workflow.transition.Transition; 12 | import lombok.Builder; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | import reactor.core.publisher.Flux; 16 | 17 | import java.awt.image.BufferedImage; 18 | import java.io.IOException; 19 | import java.nio.file.Path; 20 | import java.util.List; 21 | 22 | import static dev.langchain4j.internal.Utils.getOrDefault; 23 | import static dev.langchain4j.internal.ValidationUtils.ensureNotNull; 24 | 25 | /** 26 | * DefaultJAiWorkflow is a default implementation of the JAiWorkflow interface. 27 | * It defines the workflow for processing user messages and generating AI responses. 28 | * 29 | * @param the type of the stateful bean, which extends AbstractStatefulBean 30 | */ 31 | public class DefaultJAiWorkflow extends DefaultStateWorkflow implements JAiWorkflow { 32 | 33 | private static final Logger log = LoggerFactory.getLogger(DefaultJAiWorkflow.class); 34 | 35 | private final T statefulBean; 36 | private final Boolean runStreaming; 37 | 38 | /** 39 | * Constructs a new DefaultJAiWorkflow with the specified parameters. 40 | * 41 | * @param statefulBean the stateful bean holding the state of the workflow 42 | * @param transitions the list of transition to be performed in the workflow 43 | * @param runStreaming flag indicating whether to run the workflow in stream mode 44 | */ 45 | public DefaultJAiWorkflow(T statefulBean, 46 | List transitions, 47 | Node startNode, 48 | Boolean runStreaming) { 49 | super(DefaultStateWorkflow.builder() 50 | .statefulBean(statefulBean) 51 | .addEdges(transitions.toArray(new Transition[0]))); 52 | this.statefulBean = statefulBean; 53 | this.startNode(startNode); 54 | this.runStreaming = getOrDefault(runStreaming, false); 55 | // check if workflowOutputPath is valid 56 | // this.generateWorkflowImage = workflowImageOutputPath != null || getOrDefault(generateWorkflowImage, false); 57 | // this.workflowImageOutputPath = workflowImageOutputPath; 58 | } 59 | 60 | @Override 61 | public AiMessage answer(UserMessage question) { 62 | // Set User question to stateful bean 63 | this.statefulBean.setQuestion(question.singleText()); 64 | // Run workflow in synchronous mode 65 | this.run(); 66 | return AiMessage.from(this.statefulBean.getGeneration()); 67 | } 68 | 69 | @Override 70 | public Flux answerStream(UserMessage question) { 71 | if (!this.runStreaming || !isLastNodeAStreamingNode()) { 72 | throw new IllegalStateException("The last node of the workflow must be a StreamingNode to run in stream mode"); 73 | } 74 | // Set User question to stateful bean 75 | this.statefulBean.setQuestion(question.singleText()); 76 | // Run workflow in stream mode 77 | if (this.runStreaming) { 78 | this.runStream(node -> { 79 | if (node instanceof StreamingNode streamingNode) { 80 | log.debug("StreamingNode processed: " + streamingNode.getName()); 81 | } 82 | log.debug("Node processed: " + ((Node) node).getName()); 83 | }); 84 | } 85 | return this.statefulBean.getGenerationStream(); 86 | } 87 | 88 | @Override 89 | public JAiWorkflow getWorkflowImage(Path workflowImageOutputPath) throws IOException { 90 | if (this.wasRun()) { 91 | this.generateComputedWorkflowImage(workflowImageOutputPath.toAbsolutePath().toString()); 92 | } else { 93 | this.generateWorkflowImage(workflowImageOutputPath.toAbsolutePath().toString()); 94 | } 95 | return this; 96 | } 97 | 98 | @Override 99 | public BufferedImage getWorkflowImage() { 100 | if (this.wasRun()) { 101 | return this.generateComputedWorkflowBufferedImage(); 102 | } else { 103 | return this.generateWorkflowBufferedImage(); 104 | } 105 | } 106 | 107 | private Boolean isLastNodeAStreamingNode() { 108 | return this.getLastNode() instanceof StreamingNode; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/main/java/io/github/czelabueno/jai/workflow/langchain4j/node/StreamingNode.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.langchain4j.node; 2 | 3 | import io.github.czelabueno.jai.workflow.langchain4j.AbstractStatefulBean; 4 | import io.github.czelabueno.jai.workflow.node.Node; 5 | import dev.langchain4j.data.message.AiMessage; 6 | import dev.langchain4j.data.message.ChatMessage; 7 | import dev.langchain4j.data.message.UserMessage; 8 | import dev.langchain4j.model.StreamingResponseHandler; 9 | import dev.langchain4j.model.chat.StreamingChatLanguageModel; 10 | import dev.langchain4j.model.output.Response; 11 | import lombok.NonNull; 12 | import reactor.core.publisher.Flux; 13 | import reactor.core.publisher.Sinks; 14 | 15 | import java.util.List; 16 | import java.util.concurrent.CompletableFuture; 17 | import java.util.function.Function; 18 | 19 | import static dev.langchain4j.internal.Utils.getOrDefault; 20 | import static dev.langchain4j.internal.ValidationUtils.ensureNotBlank; 21 | 22 | /** 23 | * StreamingNode is a specialized type of {@link Node} that handles streaming responses from a {@link StreamingChatLanguageModel}. 24 | * It extends the generic Node class with specific types for stateful beans and reactive streams. 25 | * 26 | * @param the type of the stateful bean, which extends AbstractStatefulBean 27 | */ 28 | public class StreamingNode extends Node> { 29 | 30 | /** 31 | * Constructs a new StreamingNode with the specified name, messages, and StreamingChatLanguageModel. 32 | * 33 | * @param name the name of the node 34 | * @param messages the list of ChatMessage to be processed by the streamingChatLanguageModel 35 | * @param doUserMessage a function to generate a user message from the stateful bean 36 | * @param streamingChatLanguageModel the streaming chat language model to generate responses 37 | */ 38 | public StreamingNode(String name, 39 | List messages, 40 | Function doUserMessage, 41 | @NonNull StreamingChatLanguageModel streamingChatLanguageModel) { 42 | super(ensureNotBlank(name, "name"), (T statefulBean) -> streamingFunction(statefulBean, messages, doUserMessage, streamingChatLanguageModel)); 43 | } 44 | 45 | /** 46 | * Creates a new StreamingNode from the specified parameters. 47 | * 48 | * @param name the name of the node 49 | * @param messages the list of ChatMessage to be processed by the streamingChatLanguageModel 50 | * @param doUserMessage a function to generate a user message from the stateful bean 51 | * @param streamingChatLanguageModel the streaming chat language model to generate responses 52 | * @param the type of the stateful bean, which extends AbstractStatefulBean 53 | * @return a new StreamingNode instance 54 | */ 55 | public static StreamingNode from(String name, 56 | List messages, 57 | Function doUserMessage, 58 | @NonNull StreamingChatLanguageModel streamingChatLanguageModel) { 59 | return new StreamingNode(name, messages, doUserMessage, streamingChatLanguageModel); 60 | } 61 | 62 | /** 63 | * Creates a new StreamingNode from the specified parameters. 64 | * 65 | * @param name the name of the node 66 | * @param doUserMessage a function to generate a user message from the stateful bean 67 | * @param streamingChatLanguageModel the streaming chat language model to generate responses 68 | * @param the type of the stateful bean, which extends AbstractStatefulBean 69 | * @return a new StreamingNode instance 70 | */ 71 | public static StreamingNode from(String name, 72 | Function doUserMessage, 73 | @NonNull StreamingChatLanguageModel streamingChatLanguageModel) { 74 | return from(name, null, doUserMessage, streamingChatLanguageModel); 75 | } 76 | 77 | /** 78 | * Creates a new StreamingNode from the specified parameters. 79 | * 80 | * @param name the name of the node 81 | * @param messages the list of ChatMessage to be processed by the streamingChatLanguageModel 82 | * @param streamingChatLanguageModel the streaming chat language model to generate responses 83 | * @param the type of the stateful bean, which extends AbstractStatefulBean 84 | * @return a new StreamingNode instance 85 | */ 86 | public static StreamingNode from(String name, 87 | List messages, 88 | @NonNull StreamingChatLanguageModel streamingChatLanguageModel) { 89 | return from(name, messages, null, streamingChatLanguageModel); 90 | } 91 | 92 | /** 93 | * Creates a new StreamingNode from the specified parameters. 94 | * 95 | * @param name the name of the node 96 | * @param streamingChatLanguageModel the streaming chat language model to generate responses 97 | * @param the type of the stateful bean, which extends AbstractStatefulBean 98 | * @return a new StreamingNode instance 99 | */ 100 | public static StreamingNode from(String name, 101 | @NonNull StreamingChatLanguageModel streamingChatLanguageModel) { 102 | return from(name, null, null, streamingChatLanguageModel); 103 | } 104 | 105 | /** 106 | * A static function that handles the token of responses from the StreamingChatLanguageModel. 107 | * It sets up a sink to collect the streamed tokens and completes the stateful bean with the final response. 108 | * 109 | * @param statefulBean the stateful bean holding the state of the workflow 110 | * @param messages the list of ChatMessage to be processed by the streamingChatLanguageModel 111 | * @param doUserMessage a function to generate a user message from the stateful bean 112 | * @param streamingChatLanguageModel the streaming chat language model to generate responses 113 | * @param the type of the stateful bean, which extends AbstractStatefulBean 114 | * @return a Flux stream of the generated tokens 115 | */ 116 | private static Flux streamingFunction( 117 | T statefulBean, 118 | List messages, 119 | Function doUserMessage, //TODO: Function could return a list of ChatMessage 120 | StreamingChatLanguageModel streamingChatLanguageModel) { 121 | Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); 122 | CompletableFuture futureResponse = new CompletableFuture<>(); 123 | if (messages == null || messages.isEmpty()) { 124 | messages = doUserMessage != null ? 125 | List.of(doUserMessage.apply(statefulBean)) : 126 | List.of(UserMessage.from(getOrDefault(statefulBean.getQuestion(),"No question provided."))); 127 | } 128 | 129 | streamingChatLanguageModel.generate( 130 | messages, 131 | new StreamingResponseHandler() { 132 | @Override 133 | public void onNext(String token) { 134 | sink.tryEmitNext(token); 135 | } 136 | 137 | @Override 138 | public void onComplete(Response response) { 139 | futureResponse.complete(response.content()); 140 | sink.tryEmitComplete(); 141 | } 142 | 143 | @Override 144 | public void onError(Throwable throwable) { 145 | sink.tryEmitError(throwable); 146 | } 147 | } 148 | ); 149 | statefulBean.setGenerationStream(sink.asFlux().cache()); 150 | statefulBean.setGeneration(futureResponse.join().text()); 151 | return statefulBean.getGenerationStream(); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/test/java/JAiWorkflowIT.java: -------------------------------------------------------------------------------- 1 | import io.github.czelabueno.jai.workflow.langchain4j.JAiWorkflow; 2 | import io.github.czelabueno.jai.workflow.langchain4j.internal.DefaultJAiWorkflow; 3 | import io.github.czelabueno.jai.workflow.langchain4j.node.StreamingNode; 4 | import io.github.czelabueno.jai.workflow.node.Node; 5 | import dev.langchain4j.model.chat.ChatLanguageModel; 6 | import dev.langchain4j.model.chat.StreamingChatLanguageModel; 7 | import dev.langchain4j.model.mistralai.MistralAiChatModel; 8 | import dev.langchain4j.model.mistralai.MistralAiChatModelName; 9 | import dev.langchain4j.model.mistralai.MistralAiStreamingChatModel; 10 | import io.github.czelabueno.jai.workflow.transition.Transition; 11 | import lombok.SneakyThrows; 12 | import org.junit.jupiter.api.BeforeEach; 13 | import org.junit.jupiter.api.Test; 14 | import reactor.core.publisher.Flux; 15 | import reactor.test.StepVerifier; 16 | import io.github.czelabueno.jai.workflow.langchain4j.workflow.NodeFunctionsMock; 17 | import io.github.czelabueno.jai.workflow.langchain4j.workflow.StatefulBeanMock; 18 | 19 | import java.awt.image.BufferedImage; 20 | import java.nio.file.Files; 21 | import java.nio.file.Path; 22 | import java.nio.file.Paths; 23 | import java.util.Arrays; 24 | import java.util.List; 25 | 26 | import static io.github.czelabueno.jai.workflow.WorkflowStateName.END; 27 | import static io.github.czelabueno.jai.workflow.langchain4j.workflow.NodeFunctionsMock.generate; 28 | import static io.github.czelabueno.jai.workflow.langchain4j.workflow.NodeFunctionsMock.retrieve; 29 | import static java.util.stream.Collectors.joining; 30 | import static org.assertj.core.api.Assertions.assertThat; 31 | import static org.assertj.core.api.Assertions.assertThatExceptionOfType; 32 | 33 | // JAiWorkflowIT is an integration test class that demonstrates how to use JAiWorkflow with LangChain4j to build agentic systems and orchestrated AI workflows. 34 | // The workflow tested in this class is a simple example that retrieves documents, grades them, and generates a summary of the documents using the Mistral AI API. 35 | // 36 | // Workflow definition: 37 | // START -> Retrieve Node -> Grade Documents Node -> Generate Node -> END 38 | // 39 | // The setUp method initializes the JAiWorkflow and JAiWorkflowStreaming objects with the MistralAiChatModel and MistralAiStreamingChatModel classes, respectively. 40 | // These models are used to generate AI responses in both synchronous and streaming modes. 41 | // 42 | // The should_answer_question method tests the synchronous answer method of the JAiWorkflow class by providing a question and checking if the answer contains the expected text. 43 | // The should_answer_stream_question method tests the streaming answerStream method of the JAiWorkflow class by providing a question and checking if the answer contains the expected tokens. 44 | // 45 | // This integration test class showcases how JAiWorkflow and LangChain4j can be combined to create complex AI-driven workflows that can process and generate information in a structured manner. 46 | class JAiWorkflowIT { 47 | 48 | String[] documents = new String[]{ 49 | "https://lilianweng.github.io/posts/2023-06-23-agent/", 50 | "https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/", 51 | "https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/" 52 | }; 53 | 54 | ChatLanguageModel model = MistralAiChatModel.builder() 55 | .apiKey(System.getenv("MISTRAL_AI_API_KEY")) 56 | .modelName(MistralAiChatModelName.MISTRAL_LARGE_LATEST) 57 | .temperature(0.0) 58 | .build(); 59 | 60 | StreamingChatLanguageModel streamingModel = MistralAiStreamingChatModel.builder() 61 | .apiKey(System.getenv("MISTRAL_AI_API_KEY")) 62 | .modelName(MistralAiChatModelName.MISTRAL_LARGE_LATEST) 63 | .temperature(0.0) 64 | .build(); 65 | 66 | JAiWorkflow jAiWorkflow; 67 | JAiWorkflow jAiWorkflowStreaming; 68 | 69 | @BeforeEach() 70 | void setUp() { 71 | // Define a stateful bean to store the state of the workflow 72 | StatefulBeanMock statefulBean = new StatefulBeanMock(); 73 | 74 | // Define nodes with your custom functions 75 | Node retrieveNode = Node.from("Retrieve Node", obj -> retrieve(obj, documents)); 76 | Node gradeDocumentsNode = Node.from("Grade Documents Node", NodeFunctionsMock::gradeDocuments); 77 | Node generateNode = Node.from("Generate Node", obj -> generate(obj, model)); 78 | StreamingNode generateStreamingNode = StreamingNode.from( 79 | "Generate Node", 80 | NodeFunctionsMock::generateUserMessageFromStatefulBean, 81 | streamingModel); 82 | 83 | // Build workflows of the synchronous and streaming ways 84 | jAiWorkflow = new DefaultJAiWorkflow( 85 | statefulBean, 86 | Arrays.asList( 87 | Transition.from(retrieveNode, gradeDocumentsNode), 88 | Transition.from(gradeDocumentsNode, generateNode), 89 | Transition.from(generateNode, END) 90 | ), 91 | retrieveNode, 92 | false 93 | ); 94 | // Define your workflow transitions using edges and the entry point of the workflow 95 | jAiWorkflowStreaming = new DefaultJAiWorkflow( 96 | statefulBean, 97 | Arrays.asList( 98 | Transition.from(retrieveNode, gradeDocumentsNode), 99 | Transition.from(gradeDocumentsNode, generateStreamingNode), 100 | Transition.from(generateStreamingNode, END) 101 | ), 102 | retrieveNode, 103 | true 104 | ); 105 | } 106 | 107 | @Test 108 | void should_answer_question() { 109 | // given 110 | String question = "Summarizes the importance of building agents with LLMs"; 111 | 112 | // when 113 | String answer = jAiWorkflow.answer(question); 114 | BufferedImage image = jAiWorkflow.getWorkflowImage(); 115 | 116 | // then 117 | assertThat(answer).containsIgnoringWhitespaces("brain of an autonomous agent system"); 118 | assertThat(image).isNotNull(); 119 | assertThat(image.getWidth()).isGreaterThan(0); 120 | assertThat(image.getHeight()).isGreaterThan(0); 121 | } 122 | 123 | @Test 124 | void should_answer_stream_with_non_streamingNode_throw_IllegalStateException() { 125 | // given 126 | String question = "Summarizes the importance of building agents with LLMs"; 127 | 128 | // when 129 | assertThatExceptionOfType(IllegalStateException.class) 130 | .isThrownBy(() -> jAiWorkflow.answerStream(question)) 131 | .withMessage("The last node of the workflow must be a StreamingNode to run in stream mode"); 132 | } 133 | 134 | @SneakyThrows // I/O exceptions are caught 135 | @Test 136 | void should_answer_stream_question() { 137 | // given 138 | String question = "Summarizes the importance of building agents with LLMs"; 139 | List expectedTokens = Arrays.asList("building", "agent", "system","general","problem", "solver"); 140 | 141 | // when 142 | Flux tokens = jAiWorkflowStreaming.answerStream(question); 143 | String strPath = "images/corrective-rag-workflow.svg"; 144 | Path path = Paths.get(strPath); 145 | jAiWorkflowStreaming.getWorkflowImage(path); 146 | 147 | // then 148 | StepVerifier.create(tokens) 149 | .expectNextMatches(token -> expectedTokens.stream().anyMatch(token.toLowerCase()::contains)) 150 | .expectNextCount(1) 151 | .thenCancel() 152 | .verify(); 153 | String answer = tokens.collectList().block().stream().collect(joining()); 154 | assertThat(expectedTokens) 155 | .anySatisfy(token -> assertThat(answer).containsIgnoringWhitespaces(token)); 156 | assertThat(Files.exists(path)).isTrue(); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/test/java/JAiWorkflowTest.java: -------------------------------------------------------------------------------- 1 | import io.github.czelabueno.jai.workflow.langchain4j.JAiWorkflow; 2 | import dev.langchain4j.data.message.AiMessage; 3 | import dev.langchain4j.data.message.UserMessage; 4 | import org.junit.jupiter.api.BeforeEach; 5 | import org.junit.jupiter.api.Test; 6 | import reactor.core.publisher.Flux; 7 | import reactor.test.StepVerifier; 8 | 9 | import static org.assertj.core.api.Assertions.assertThat; 10 | import static org.assertj.core.api.Assertions.assertThatExceptionOfType; 11 | import static org.mockito.Mockito.*; 12 | 13 | class JAiWorkflowTest { 14 | 15 | private JAiWorkflow jAiWorkflow; 16 | private UserMessage userMessage; 17 | private AiMessage aiMessage; 18 | 19 | @BeforeEach 20 | void setUp() { 21 | jAiWorkflow = mock(JAiWorkflow.class); 22 | userMessage = new UserMessage("What is the weather today?"); 23 | aiMessage = new AiMessage("The weather is sunny today."); 24 | } 25 | 26 | @Test 27 | void should_answer_with_valid_question() { 28 | // given 29 | when(jAiWorkflow.answer(userMessage)).thenReturn(aiMessage); 30 | // when 31 | AiMessage response = jAiWorkflow.answer(userMessage); 32 | // then 33 | assertThat(response).isNotNull(); 34 | assertThat(response.text()).isEqualTo("The weather is sunny today."); 35 | verify(jAiWorkflow, times(1)).answer(userMessage); 36 | } 37 | 38 | @Test 39 | void should_throw_exception_with_null_question() { 40 | // when 41 | when(jAiWorkflow.answer((String) null)).thenThrow(new NullPointerException("question")); 42 | // then 43 | assertThatExceptionOfType(NullPointerException.class) 44 | .isThrownBy(() -> jAiWorkflow.answer((String) null)) 45 | .withMessage("question"); 46 | verify(jAiWorkflow, times(1)).answer((String) null); 47 | } 48 | 49 | @Test 50 | void should_answer_stream_with_valid_question() { 51 | // given 52 | when(jAiWorkflow.answerStream(userMessage)).thenReturn(Flux.just("The", "weather", "is", "sunny", "today.")); 53 | // when 54 | Flux response = jAiWorkflow.answerStream(userMessage); 55 | // then 56 | assertThat(response).isNotNull(); 57 | StepVerifier.create(response) 58 | .expectNext("The", "weather", "is", "sunny", "today.") 59 | .verifyComplete(); 60 | verify(jAiWorkflow, times(1)).answerStream(userMessage); 61 | } 62 | 63 | @Test 64 | void should_throw_exception_with_null_question_stream() { 65 | // when 66 | when(jAiWorkflow.answerStream((String) null)).thenThrow(new NullPointerException("question")); 67 | // then 68 | assertThatExceptionOfType(NullPointerException.class) 69 | .isThrownBy(() -> jAiWorkflow.answerStream((String) null)) 70 | .withMessage("question"); 71 | verify(jAiWorkflow, times(1)).answerStream((String) null); 72 | } 73 | 74 | @Test 75 | void should_answer_stream_with_non_streamingNode() { 76 | // given 77 | when(jAiWorkflow.answerStream(userMessage)).thenThrow(new IllegalStateException("The last node of the workflow must be a StreamingNode to run in stream mode")); 78 | // when 79 | assertThatExceptionOfType(IllegalStateException.class) 80 | .isThrownBy(() -> jAiWorkflow.answerStream(userMessage)) 81 | .withMessage("The last node of the workflow must be a StreamingNode to run in stream mode"); 82 | verify(jAiWorkflow, times(1)).answerStream(userMessage); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/test/java/io/github/czelabueno/jai/workflow/langchain4j/node/StreamingNodeTest.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.langchain4j.node; 2 | 3 | import io.github.czelabueno.jai.workflow.langchain4j.AbstractStatefulBean; 4 | import dev.langchain4j.data.message.AiMessage; 5 | import dev.langchain4j.data.message.ChatMessage; 6 | import dev.langchain4j.data.message.UserMessage; 7 | import dev.langchain4j.model.StreamingResponseHandler; 8 | import dev.langchain4j.model.chat.StreamingChatLanguageModel; 9 | import dev.langchain4j.model.output.Response; 10 | import org.junit.jupiter.api.BeforeEach; 11 | import org.junit.jupiter.api.Test; 12 | import reactor.test.StepVerifier; 13 | 14 | import java.util.Arrays; 15 | import java.util.List; 16 | 17 | import static org.mockito.ArgumentMatchers.any; 18 | import static org.assertj.core.api.Assertions.assertThat; 19 | import static org.assertj.core.api.Assertions.assertThatExceptionOfType; 20 | import static org.mockito.Mockito.*; 21 | 22 | class StreamingNodeTest { 23 | 24 | private StreamingChatLanguageModel model; 25 | private MyStatefulBean statefulBean; 26 | private List messages; 27 | 28 | class MyStatefulBean extends AbstractStatefulBean{ 29 | List documents; 30 | 31 | public MyStatefulBean(List documents) { 32 | this.documents = documents; 33 | } 34 | 35 | @Override 36 | public String toString() { 37 | return "MyStatefulBean{" + 38 | "documents=" + documents + 39 | '}'; 40 | } 41 | } 42 | 43 | @BeforeEach 44 | void setUp() { 45 | model = mock(StreamingChatLanguageModel.class); 46 | statefulBean = new MyStatefulBean(List.of("document1", "document2")); 47 | messages = List.of(new UserMessage("What is the weather today?")); 48 | } 49 | 50 | @Test 51 | void should_create_streaming_node_using_from() { 52 | // given 53 | StreamingNode node = StreamingNode.from("streamingNode1", messages, model); 54 | // then 55 | assertThat(node).isNotNull(); 56 | assertThat(node.getName()).isEqualTo("streamingNode1"); 57 | } 58 | 59 | @Test 60 | void should_streaming_function_with_valid_inputs() { 61 | // given 62 | List tokens = Arrays.asList("The", "weather", "is", "sunny", "today."); 63 | doAnswer(invocation -> { 64 | StreamingResponseHandler handler = invocation.getArgument(1); 65 | tokens.forEach(handler::onNext); 66 | handler.onComplete(new Response<>(new AiMessage("The weather is sunny today."))); 67 | return null; 68 | }).when(model).generate(anyList(), any(StreamingResponseHandler.class)); 69 | // when 70 | StreamingNode node = StreamingNode.from("streamingNode1", messages, model); 71 | node.execute(statefulBean); 72 | // then 73 | StepVerifier.create(statefulBean.getGenerationStream()) 74 | .expectNext("The", "weather", "is", "sunny", "today.") 75 | .verifyComplete(); 76 | assertThat(statefulBean.getGeneration()).isEqualTo("The weather is sunny today."); 77 | } 78 | 79 | @Test 80 | void should_throw_null_pointer_exception_if_streamingChatLanguageModel_is_null() { 81 | // then 82 | assertThatExceptionOfType(NullPointerException.class) 83 | .isThrownBy(() -> StreamingNode.from("streamingNode1", messages, null)) 84 | .withMessage("streamingChatLanguageModel is marked non-null but is null"); 85 | } 86 | 87 | @Test 88 | void should_throw_illegal_argument_exception() { 89 | // then 90 | assertThatExceptionOfType(IllegalArgumentException.class) 91 | .isThrownBy(() -> StreamingNode.from(null, messages, model)) 92 | .withMessage("name cannot be null or blank"); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/test/java/io/github/czelabueno/jai/workflow/langchain4j/workflow/NodeFunctionsMock.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.langchain4j.workflow; 2 | 3 | import dev.langchain4j.data.document.Document; 4 | import dev.langchain4j.data.document.loader.UrlDocumentLoader; 5 | import dev.langchain4j.data.document.parser.TextDocumentParser; 6 | import dev.langchain4j.data.document.splitter.DocumentSplitters; 7 | import dev.langchain4j.data.document.transformer.jsoup.HtmlToTextDocumentTransformer; 8 | import dev.langchain4j.data.message.UserMessage; 9 | import dev.langchain4j.data.segment.TextSegment; 10 | import dev.langchain4j.model.chat.ChatLanguageModel; 11 | import dev.langchain4j.model.input.Prompt; 12 | import dev.langchain4j.model.input.structured.StructuredPromptProcessor; 13 | import dev.langchain4j.rag.content.Content; 14 | import io.github.czelabueno.jai.workflow.langchain4j.workflow.prompt.GenerateAnswerPrompt; 15 | 16 | import java.util.ArrayList; 17 | import java.util.List; 18 | 19 | import static java.util.stream.Collectors.toList; 20 | 21 | public class NodeFunctionsMock { 22 | 23 | private NodeFunctionsMock() { 24 | } 25 | 26 | public static StatefulBeanMock retrieve(StatefulBeanMock statefulBean, String... uris) { 27 | // Parse uris to documents 28 | List documents = new ArrayList<>(); 29 | for (String uri : uris) { 30 | Document document = UrlDocumentLoader.load(uri,new TextDocumentParser()); 31 | HtmlToTextDocumentTransformer transformer = new HtmlToTextDocumentTransformer(null, null, false); 32 | document = transformer.transform(document); 33 | documents.add(document); 34 | } 35 | // Mock retrieval that only gets the first 7 segments as a relevant document 36 | List segments = DocumentSplitters 37 | .recursive(300,0) 38 | .splitAll(documents); 39 | List relevantDocuments = segments.stream() 40 | .limit(7) 41 | .map(segment -> new Content(segment.text())) 42 | .collect(toList()); 43 | statefulBean.setDocuments(relevantDocuments.stream() 44 | .map(Content::textSegment) 45 | .map(TextSegment::text) 46 | .toList()); 47 | return statefulBean; 48 | } 49 | 50 | public static StatefulBeanMock gradeDocuments(StatefulBeanMock statefulBean) { 51 | // Mock grading that return that doc is relevant 52 | List docs = statefulBean.getDocuments(); 53 | List filteredDocs = docs.stream() 54 | .filter(doc -> doc.length() > 0) // feeble filter to return the first doc 55 | .toList(); 56 | statefulBean.setDocuments(filteredDocs); 57 | statefulBean.setWebSearch("No"); // do not require go to web search because doc is relevant 58 | return statefulBean; 59 | } 60 | 61 | public static StatefulBeanMock generate(StatefulBeanMock statefulBean, ChatLanguageModel model) { 62 | String generation = model.generate(generateUserMessageFromStatefulBean(statefulBean).singleText()); 63 | statefulBean.setGeneration(generation); 64 | return statefulBean; 65 | } 66 | 67 | public static UserMessage generateUserMessageFromStatefulBean(StatefulBeanMock statefulBean) { 68 | return UserMessage.from(answerPrompt(statefulBean).text()); 69 | } 70 | 71 | private static Prompt answerPrompt(StatefulBeanMock statefulBean) { 72 | String question = statefulBean.getQuestion(); 73 | String context = String.join("\n\n", statefulBean.getDocuments()); 74 | GenerateAnswerPrompt generateAnswerPrompt = new GenerateAnswerPrompt(question, context); 75 | return StructuredPromptProcessor.toPrompt(generateAnswerPrompt); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/test/java/io/github/czelabueno/jai/workflow/langchain4j/workflow/StatefulBeanMock.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.langchain4j.workflow; 2 | 3 | import io.github.czelabueno.jai.workflow.langchain4j.AbstractStatefulBean; 4 | import lombok.Data; 5 | 6 | import java.util.List; 7 | 8 | @Data 9 | public class StatefulBeanMock extends AbstractStatefulBean { 10 | 11 | private List documents; 12 | private String webSearch; 13 | 14 | public StatefulBeanMock() { 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/test/java/io/github/czelabueno/jai/workflow/langchain4j/workflow/prompt/GenerateAnswerPrompt.java: -------------------------------------------------------------------------------- 1 | package io.github.czelabueno.jai.workflow.langchain4j.workflow.prompt; 2 | 3 | import dev.langchain4j.model.input.structured.StructuredPrompt; 4 | 5 | @StructuredPrompt({ 6 | "You are an assistant for question-answering tasks. ", 7 | "Use the following pieces of retrieved context to answer the question. ", 8 | "If you don't know the answer, just say that you don't know. ", 9 | "Use three sentences maximum and keep the answer concise.", 10 | 11 | "Question: {{question}} \n\n", 12 | "Context: {{context}} \n\n", 13 | "Answer:" 14 | }) 15 | public class GenerateAnswerPrompt { 16 | 17 | private String question; 18 | private String context; 19 | 20 | public GenerateAnswerPrompt(String question, String context) { 21 | this.question = question; 22 | this.context = context; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /jai-workflow-langchain4j/src/test/resources/tinylog.properties: -------------------------------------------------------------------------------- 1 | writer.level = debug 2 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | io.github.czelabueno 8 | jai-workflow-parent 9 | 0.3.0-SNAPSHOT 10 | pom 11 | 12 | JavAI Workflow 13 | JavAI Workflow: Flexible workflow engine to build agentic, enterprise and modular RAG applications for Java 14 | https://github.com/czelabueno/jai-workflow 15 | 16 | 17 | GitHub 18 | https://github.com/czelabueno/jai-workflow/issues 19 | 20 | 21 | 22 | GitHub Actions 23 | https://github.com/czelabueno/jai-workflow/actions 24 | 25 | 26 | 27 | scm:git:git://github.com/czelabueno/jai-workflow.git 28 | scm:git:ssh://github.com:czelabueno/jai-workflow.git 29 | https://github.com/czelabueno/jai-workflow/tree/main 30 | 31 | 32 | 33 | 34 | The Apache License, Version 2.0 35 | http://www.apache.org/licenses/LICENSE-2.0.txt 36 | 37 | 38 | 39 | 40 | 41 | czelabueno 42 | Carlos Zela 43 | c.zelabueno@gmail.com 44 | jAI Workflow 45 | https://github.com/czelabueno/jai-workflow 46 | 47 | 48 | 49 | 50 | 51 | ossrh 52 | https://s01.oss.sonatype.org/content/repositories/snapshots 53 | 54 | 55 | 56 | 57 | UTF-8 58 | UTF-8 59 | 0.2.0 60 | 17 61 | ${java.version} 62 | ${java.version} 63 | 64 | 1.18.30 65 | 1.5.3 66 | 2.0.7 67 | 0.18.1 68 | 21.3.0 69 | 5.14.2 70 | 3.25.3 71 | 5.10.0 72 | 2.6.2 73 | 74 | 75 | 76 | 77 | 78 | 79 | org.projectlombok 80 | lombok 81 | ${lombok.version} 82 | 83 | 84 | 85 | 86 | guru.nidi 87 | graphviz-java 88 | ${graphviz-java.version} 89 | 90 | 91 | 92 | guru.nidi 93 | graphviz-rough 94 | ${graphviz-java.version} 95 | 96 | 97 | 98 | org.graalvm.js 99 | js 100 | ${graal.js.version} 101 | 102 | 103 | 104 | 105 | org.junit.jupiter 106 | junit-jupiter-engine 107 | ${junit.version} 108 | 109 | 110 | 111 | org.assertj 112 | assertj-core 113 | ${assertj.version} 114 | 115 | 116 | 117 | org.mockito 118 | mockito-bom 119 | ${mockito.version} 120 | pom 121 | import 122 | 123 | 124 | 125 | org.mockito 126 | mockito-core 127 | ${mockito.version} 128 | 129 | 130 | 131 | ch.qos.logback 132 | logback-classic 133 | ${logback.version} 134 | 135 | 136 | 137 | org.slf4j 138 | slf4j-api 139 | ${slf4j-api.version} 140 | 141 | 142 | 143 | org.tinylog 144 | tinylog-impl 145 | ${tinylog.version} 146 | 147 | 148 | 149 | org.tinylog 150 | slf4j-tinylog 151 | ${tinylog.version} 152 | 153 | 154 | 155 | 156 | 157 | 158 | jai-workflow-core 159 | jai-workflow-langchain4j 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | org.honton.chas 168 | license-maven-plugin 169 | 0.0.6 170 | 171 | 172 | 173 | org.apache.maven.plugins 174 | maven-jar-plugin 175 | 3.3.0 176 | 177 | 178 | 179 | org.apache.maven.plugins 180 | maven-resources-plugin 181 | 3.3.1 182 | 183 | 184 | 185 | org.apache.maven.plugins 186 | maven-install-plugin 187 | 3.1.3 188 | 189 | 190 | 191 | org.apache.maven.plugins 192 | maven-deploy-plugin 193 | 3.1.3 194 | 195 | 196 | 197 | org.apache.maven.plugins 198 | maven-clean-plugin 199 | 3.3.2 200 | 201 | 202 | 203 | org.apache.maven.plugins 204 | maven-compiler-plugin 205 | 3.13.0 206 | 207 | 208 | 209 | org.projectlombok 210 | lombok 211 | ${lombok.version} 212 | 213 | 214 | 215 | 216 | 217 | 218 | org.apache.maven.plugins 219 | maven-source-plugin 220 | 3.2.1 221 | 222 | 223 | attach-sources 224 | 225 | jar-no-fork 226 | 227 | 228 | 229 | 230 | 231 | 232 | org.apache.maven.plugins 233 | maven-javadoc-plugin 234 | 3.5.0 235 | 236 | 237 | attach-javadocs 238 | 239 | jar 240 | 241 | 242 | 243 | aggregate 244 | 245 | aggregate 246 | 247 | site 248 | 249 | 250 | 251 | 252 | 253 | org.apache.maven.plugins 254 | maven-surefire-plugin 255 | 3.1.2 256 | 257 | 258 | **/*Test.java 259 | 260 | 261 | 262 | 263 | 264 | org.apache.maven.plugins 265 | maven-failsafe-plugin 266 | 3.5.2 267 | 268 | 269 | 270 | integration-test 271 | verify 272 | 273 | 274 | 275 | 276 | 277 | 278 | org.apache.maven.plugins 279 | maven-dependency-plugin 280 | 3.8.1 281 | 282 | 283 | 284 | properties 285 | 286 | 287 | 288 | detect-unused-dependencies 289 | 290 | analyze-only 291 | 292 | 293 | true 294 | false 295 | 296 | 297 | 298 | analyze-all 299 | 300 | analyze-only 301 | 302 | 303 | 304 | 305 | 306 | 307 | org.sonatype.plugins 308 | nexus-staging-maven-plugin 309 | 1.6.13 310 | true 311 | 312 | ossrh 313 | https://s01.oss.sonatype.org/ 314 | false 315 | 316 | 317 | 318 | 319 | org.codehaus.mojo 320 | versions-maven-plugin 321 | 2.18.0 322 | 323 | 324 | 325 | 326 | 327 | 328 | allModules 329 | 330 | true 331 | 332 | 333 | jai-workflow-core 334 | jai-workflow-langchain4j 335 | 336 | 337 | 338 | release 339 | 340 | jai-workflow-core 341 | jai-workflow-langchain4j 342 | 343 | 344 | 345 | ossrh 346 | https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/ 347 | 348 | 349 | 350 | 351 | 352 | org.apache.maven.plugins 353 | maven-source-plugin 354 | 3.2.1 355 | 356 | 357 | attach-sources 358 | 359 | jar-no-fork 360 | 361 | 362 | 363 | 364 | 365 | 366 | org.apache.maven.plugins 367 | maven-javadoc-plugin 368 | 3.2.0 369 | 370 | 371 | ${java.version} 372 | 373 | false 374 | ${java.version} 375 | 376 | 377 | 378 | attach-javadocs 379 | 380 | jar 381 | 382 | 383 | 384 | 385 | 386 | 387 | org.apache.maven.plugins 388 | maven-gpg-plugin 389 | 1.6 390 | 391 | 392 | sign-artifacts 393 | verify 394 | 395 | sign 396 | 397 | 398 | 399 | --pinentry-mode 400 | loopback 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | org.sonatype.plugins 409 | nexus-staging-maven-plugin 410 | 1.6.13 411 | true 412 | 413 | ossrh 414 | https://s01.oss.sonatype.org/ 415 | true 416 | false 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | --------------------------------------------------------------------------------