├── .gitignore ├── .idea ├── .gitignore └── inspectionProfiles │ └── Project_Default.xml ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── labs.md ├── settings.gradle.kts └── src ├── main ├── java │ └── com │ │ └── kousenit │ │ ├── DalleService.java │ │ ├── EasyRAGDemo.java │ │ ├── OllamaRecords.java │ │ ├── OllamaService.java │ │ ├── OpenAiRecords.java │ │ ├── OpenAiService.java │ │ ├── TextToSpeechService.java │ │ └── Utils.java └── resources │ ├── cats_playing_cards.png │ ├── documents │ └── pg1661_adventures_of_sherlock_holmes.txt │ └── logback.xml └── test └── java └── com └── kousenit ├── DalleServiceTest.java ├── OllamaLC4jTest.java ├── OllamaServiceTest.java ├── OpenAiLC4jTest.java ├── OpenAiServiceTest.java └── TextToSpeechServiceTest.java /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### IntelliJ IDEA ### 8 | .idea/modules.xml 9 | .idea/jarRepositories.xml 10 | .idea/compiler.xml 11 | .idea/gradle.xml 12 | .idea/libraries/ 13 | .idea/misc.xml 14 | .idea/dictionaries/kousen.xml 15 | .idea/vcs.xml 16 | .idea/uiDesigner.xml 17 | *.iws 18 | *.iml 19 | *.ipr 20 | out/ 21 | !**/src/main/**/out/ 22 | !**/src/test/**/out/ 23 | 24 | ### Eclipse ### 25 | .apt_generated 26 | .classpath 27 | .factorypath 28 | .project 29 | .settings 30 | .springBeans 31 | .sts4-cache 32 | bin/ 33 | !**/src/main/**/bin/ 34 | !**/src/test/**/bin/ 35 | 36 | ### NetBeans ### 37 | /nbproject/private/ 38 | /nbbuild/ 39 | /dist/ 40 | /nbdist/ 41 | /.nb-gradle/ 42 | 43 | ### VS Code ### 44 | .vscode/ 45 | 46 | ### Mac OS ### 47 | .DS_Store 48 | /.idea/ 49 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ken Kousen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Exercises for a course on adding AI tools to Java. 2 | 3 | See the file [labs.md](labs.md) for the actual exercises. The code in this repo contains the solutions. 4 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("java") 3 | id("com.diffplug.spotless") version "6.25.0" 4 | } 5 | 6 | group = "com.kousenit" 7 | version = "1.0-SNAPSHOT" 8 | 9 | repositories { 10 | mavenCentral() 11 | } 12 | 13 | dependencies { 14 | implementation(platform("dev.langchain4j:langchain4j-bom:1.0.1")) 15 | 16 | // LangChain4j Easy RAG example 17 | implementation("dev.langchain4j:langchain4j") 18 | implementation("dev.langchain4j:langchain4j-open-ai") 19 | implementation("dev.langchain4j:langchain4j-ollama") 20 | implementation("dev.langchain4j:langchain4j-google-ai-gemini") 21 | implementation("dev.langchain4j:langchain4j-easy-rag") { 22 | exclude(group = "org.apache.logging.log4j", module = "log4j-api") 23 | } 24 | 25 | // Security fix 26 | implementation("org.apache.poi:poi-ooxml:5.4.0") 27 | 28 | // Gson parser 29 | implementation("com.google.code.gson:gson:2.13.1") 30 | 31 | // Logging 32 | implementation("org.slf4j:slf4j-simple:2.0.17") 33 | 34 | // Testing libraries 35 | testImplementation(platform("org.junit:junit-bom:5.12.2")) 36 | testImplementation("org.junit.jupiter:junit-jupiter") 37 | testImplementation("org.assertj:assertj-core:3.27.3") 38 | testRuntimeOnly("org.junit.platform:junit-platform-launcher") 39 | } 40 | 41 | tasks.test { 42 | useJUnitPlatform() 43 | jvmArgs("-XX:+EnableDynamicAgentLoading", "-Xshare:off") 44 | } 45 | 46 | spotless { 47 | java { 48 | target("src/**/*.java") 49 | palantirJavaFormat("2.63.0") 50 | } 51 | 52 | format("misc") { 53 | target("*.gradle", "*.gradle.kts", "*.md", ".gitignore") 54 | trimTrailingWhitespace() 55 | indentWithSpaces(4) 56 | endWithNewline() 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kousen/AiJavaLabs/35ea906c10b0608d7ba73d40aefdbb48de870325/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Jul 08 12:33:23 EDT 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Use "xargs" to parse quoted args. 209 | # 210 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 211 | # 212 | # In Bash we could simply go: 213 | # 214 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 215 | # set -- "${ARGS[@]}" "$@" 216 | # 217 | # but POSIX shell has neither arrays nor command substitution, so instead we 218 | # post-process each arg (as a line of input to sed) to backslash-escape any 219 | # character that might be a shell metacharacter, then use eval to reverse 220 | # that process (while maintaining the separation between arguments), and wrap 221 | # the whole thing up as a single "set" statement. 222 | # 223 | # This will of course break if any of these variables contains a newline or 224 | # an unmatched quote. 225 | # 226 | 227 | eval "set -- $( 228 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 229 | xargs -n1 | 230 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 231 | tr '\n' ' ' 232 | )" '"$@"' 233 | 234 | exec "$JAVACMD" "$@" 235 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /labs.md: -------------------------------------------------------------------------------- 1 | # Labs 2 | 3 | ## Generate Audio from Text 4 | 5 | - At the [OpenAI developer site](https://platform.openai.com/docs/overview), sign up for an account and create an API key. Currently the link to your API keys is at https://platform.openai.com/api-keys . OpenAI now recommends project API keys. See [this page](https://help.openai.com/en/articles/9186755-managing-your-work-in-the-api-platform-with-projects) for more information. 6 | - Add the API key to an environment variable called `OPENAI_API_KEY`. 7 | - Create a new Java project called `aijavalabs` in your favorite IDE with either Gradle or Maven as your build tool. 8 | - Review the documentation for the Open AI [Text-to-speech](https://platform.openai.com/docs/guides/text-to-speech) API. 9 | - Create a new class called `TextToSpeechService` in a package of your choosing. 10 | - Add a private, static, final attribute called `OPENAI_API_KEY` of type `String` that reads the API key from the environment variable. 11 | 12 | ```java 13 | private static final String OPENAI_API_KEY = System.getenv("OPENAI_API_KEY"); 14 | ``` 15 | - Add a method called `generateMp3` that returns a `java.nio.file.Path`. The method should take three arguments: 16 | - `model` of type `String` 17 | - `input` of type `String` 18 | - `voice` of type `String` 19 | 20 | - Add a local variable called `payload`, which is a JSON string that contains the text you want to convert to speech: 21 | 22 | ```java 23 | String payload = """ 24 | { 25 | "model": "%s", 26 | "input": "%s", 27 | "voice": "%s" 28 | } 29 | """.formatted(model, input.replaceAll("\\s+", " ").trim(), voice); 30 | ``` 31 | 32 | - For those parameters: 33 | - The value of the `model` variable must be either `tts-1` or `tts-1-hd`. The non-hd version is almost certainly good enough, but use whichever one you prefer. 34 | - The value of `voice` must be one of: `alloy`, `echo`, `fable`, `onyx`, `nova`, or `shimmer`. Pick any one you like. 35 | - The `input` parameter is the text you wish to convert to an mp3. 36 | 37 | - Next, create an `HttpRequest` object that will include the `payload` in a POST request: 38 | 39 | ```java 40 | HttpRequest request = HttpRequest.newBuilder() 41 | .uri(URI.create("https://api.openai.com/v1/audio/speech")) 42 | .header("Authorization", "Bearer %s".formatted(OPENAI_API_KEY)) 43 | .header("Content-Type", "application/json") 44 | .header("Accept", "audio/mpeg") 45 | .POST(HttpRequest.BodyPublishers.ofString(payload)) 46 | .build(); 47 | ``` 48 | 49 | - To send a request, you need an `HttpClient` instance. Starting in Java 21, the `HttpClient` class implements `AutoCloseable`, so you can use it in a try-with-resources block. Here is the code to send the request: 50 | 51 | ```java 52 | public Path generateMp3(String model, String input, String voice) { 53 | 54 | // ... from before ... 55 | 56 | try (HttpClient client = HttpClient.newHttpClient()) { 57 | HttpResponse response = 58 | client.send(request, HttpResponse.BodyHandlers.ofFile(getFilePath())); 59 | return response.body(); 60 | } catch (IOException | InterruptedException e) { 61 | throw new RuntimeException(e); 62 | } 63 | } 64 | ``` 65 | 66 | - You'll likely want to save the generated audio file into a unique file. The argument to `ofFile` is a `Path` object that represents the file where the response will be saved. Add the following private method to generate one: 67 | 68 | ```java 69 | private Path getFilePath() { 70 | String timestamp = LocalDateTime.now() 71 | .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); 72 | String fileName = String.format("audio_%s.mp3", timestamp); 73 | Path dir = Paths.get("src/main/resources"); 74 | return dir.resolve(fileName); 75 | } 76 | ``` 77 | 78 | - Add a test to drive the system: 79 | 80 | ```java 81 | 82 | @Test 83 | void testGenerateMp3() { 84 | var service = new TextToSpeechService(); 85 | Path result = service.generateMp3( 86 | "tts-1", 87 | """ 88 | Now that I know how to generate an audio file, 89 | I can use it to add the ability to convert 90 | text to speech to any existing Java system. 91 | """, 92 | "alloy" 93 | ); 94 | 95 | assertNotNull(result); 96 | System.out.println("Generated audio file: " + result.toAbsolutePath()); 97 | } 98 | ``` 99 | 100 | - Run the test. The console should show the name of an mp3 file saved in the `src/main/resources` folder. You can play the file to hear the text you provided. 101 | 102 | ## List the OpenAI Models 103 | 104 | - Whenever you access the OpenAI API, you need to specify a model. To know which models are available, the OpenAI API provides an endpoint to list them. Review the documentation for that endpoint [here](https://platform.openai.com/docs/api-reference/models). 105 | - You'll find that our task is to send an HTTP GET request to `https://api.openai.com/v1/models` with the required `Authorization` header, and that the result is a block of JSON data. 106 | - We're going to want to parse that JSON data, as well as generate JSON for other interactions. For that, we need a parser. We'll use the Gson library from Google. Add the following dependency to your build file: 107 | 108 | ```kotlin 109 | dependencies { 110 | implementation("com.google.code.gson:gson:2.13.1") 111 | } 112 | ``` 113 | 114 | - Add a class called `OpenAiRecords` to your project. It will hold all the records we need to parse the JSON data. 115 | - Inside the `OpenAiRecords` class, add a record called `ModelList`, which itself contains a record called `Model`: 116 | 117 | ```java 118 | import com.google.gson.annotations.SerializedName; 119 | import java.util.List; 120 | 121 | public class OpenAiRecords { 122 | // List the models 123 | public record ModelList(List data) { 124 | public record Model( 125 | String id, 126 | long created, 127 | @SerializedName("owned_by") String ownedBy) { 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | - Create a new class called `OpenAiService` in a package of your choosing. 134 | - Add a private, static, final attribute called `OPENAI_API_KEY` of type `String` that reads the API key from the environment variable. 135 | 136 | ```java 137 | private static final String API_KEY = System.getenv("OPENAI_API_KEY"); 138 | ``` 139 | 140 | - Also add a constant for the Models endpoint: 141 | 142 | ```java 143 | private static final String MODELS_URL = "https://api.openai.com/v1/models"; 144 | ``` 145 | 146 | - Add a private, static, final attribute called `GSON` of type `Gson` that creates a new `Gson` instance: 147 | 148 | ```java 149 | private final Gson gson = new GsonBuilder() 150 | .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 151 | .create(); 152 | ``` 153 | 154 | - Add a method called `listModels` that returns a `List`. This method will send a GET request to the OpenAI API and return a `ModelList`. 155 | 156 | ```java 157 | public ModelList listModels() { 158 | HttpRequest request = HttpRequest.newBuilder() 159 | .uri(URI.create(MODELS_URL)) 160 | .header("Authorization", "Bearer %s".formatted(API_KEY)) 161 | .header("Accept", "application/json") 162 | .build(); 163 | try (var client = HttpClient.newHttpClient()) { 164 | HttpResponse response = 165 | client.send(request, HttpResponse.BodyHandlers.ofString()); 166 | return gson.fromJson(response.body(), ModelList.class); 167 | } catch (IOException | InterruptedException e) { 168 | throw new RuntimeException(e); 169 | } 170 | } 171 | ``` 172 | 173 | - Add a test to drive the system: 174 | 175 | ```java 176 | import org.junit.jupiter.api.Test; 177 | 178 | import java.util.HashSet; 179 | import java.util.List; 180 | 181 | import static com.kousenit.OpenAiRecords.ModelList; 182 | import static org.junit.jupiter.api.Assertions.assertTrue; 183 | 184 | class OpenAiServiceTest { 185 | private final OpenAiService service = new OpenAiService(); 186 | 187 | @Test 188 | void listModels() { 189 | List models = service.listModels().data().stream() 190 | .map(ModelList.Model::id) 191 | .sorted() 192 | .toList(); 193 | 194 | models.forEach(System.out::println); 195 | assertTrue(new HashSet<>(models).containsAll( 196 | List.of("dall-e-3", "gpt-3.5-turbo", "gpt-4o", 197 | "tts-1", "whisper-1"))); 198 | } 199 | } 200 | ``` 201 | 202 | - Run the test. You should see a list of model names in the console, and the assertion should pass. 203 | 204 | ## Install Ollama 205 | 206 | - Download the latest version of Ollama from the [Ollama website](https://ollama.com). 207 | - Run the installer and follow the instructions. 208 | - From a command prompt, enter `ollama run gemma2` to download and install the Gemma 2 model from Google (an open source model based on their Gemini model) locally. 209 | - Try out a couple of sample prompts at the command line, like `Why is the sky blue?` or `Given the power required to train large language models, how do companies ever expect to make money?`. 210 | - When you're finished, type `/bye` to exit. 211 | 212 | ## Access Ollama programmatically 213 | 214 | - Ollama installs a small web server on your system. Verify that it is running by accessing [http://localhost:11434](http://localhost:11434) in a browser. You should get back the string, `Ollama is running`. 215 | - Ollama maintains a programmatic API accessible through a RESTful web service. The documentation is located [here](https://github.com/ollama/ollama/blob/main/docs/api.md). 216 | - We'll start by accessing the `api/generate` endpoint, as it is the simplest. It takes a JSON object with three fields: `model`, `prompt`, and `stream`. The `model` field is the name of the model you want to use (`gemma2` in our case), the `prompt` field is the question you want the model to answer, and the `stream` field is a boolean that determines whether the response should be streamed back to the client. 217 | - Create a Java project (or simply add to the existing one) in your favorite IDE with either Gradle or Maven as your build tool. 218 | - Create a class called `OllamaService` in a package of your choosing. 219 | - Add a method called `generate` that takes a `String` and returns a `String`. 220 | - If you created a new project, add the Gson dependency to your build file and synchronize the project. If you added to an existing project, you can skip this step. 221 | - Model the input and output JSON with Java records. To do so, add a class called `OllamaRecords` to your project. 222 | 223 | ```java 224 | public class OllamaRecords { 225 | public record OllamaTextRequest(String model, String prompt, boolean stream) { 226 | } 227 | } 228 | ``` 229 | 230 | - The output consists of a JSON object with `model`, `created_at`, `response`, and `done` field (among other optional fields we won't use right now). Add a record for that: 231 | 232 | ```java 233 | public class OllamaRecords { 234 | public record OllamaTextRequest( 235 | String model, 236 | String prompt, 237 | boolean stream) { 238 | } 239 | 240 | public record OllamaResponse( 241 | String model, 242 | String createdAt, 243 | String response, 244 | boolean done) { 245 | } 246 | } 247 | ``` 248 | 249 | - Now we have enough to create the `OllamaService` class and use it to access the chat endpoint. Add the following code to the `OllamaService` class: 250 | 251 | ```java 252 | import com.google.gson.Gson; 253 | import com.google.gson.GsonBuilder; 254 | import java.io.IOException; 255 | import java.net.URI; 256 | import java.net.http.HttpClient; 257 | import java.net.http.HttpRequest; 258 | import java.net.http.HttpResponse; 259 | import java.net.http.HttpResponse.BodyHandlers; 260 | 261 | import static com.kousenit.OllamaRecords.*; 262 | 263 | public class OllamaService { 264 | private static final HttpClient client = HttpClient.newHttpClient(); 265 | private static final String URL = "http://localhost:11434"; 266 | 267 | private final Gson gson = new GsonBuilder() 268 | .setPrettyPrinting() 269 | .create(); 270 | 271 | public OllamaResponse generate(OllamaTextRequest ollamaRequest) { 272 | HttpRequest request = HttpRequest.newBuilder() 273 | .uri(URI.create(URL + "/api/generate")) 274 | .header("Content-Type", "application/json") 275 | .header("Accept", "application/json") 276 | .POST(HttpRequest.BodyPublishers.ofString( 277 | gson.toJson(ollamaRequest))) 278 | .build(); 279 | try { 280 | HttpResponse response = 281 | client.send(request, HttpResponse.BodyHandlers.ofString()); 282 | return gson.fromJson(response.body(), OllamaResponse.class); 283 | } catch (IOException | InterruptedException e) { 284 | throw new RuntimeException(e); 285 | } 286 | } 287 | } 288 | ``` 289 | 290 | * Create a test class to verify the `OllamaService` class works as expected. Here is a sample: 291 | 292 | ```java 293 | import org.junit.jupiter.api.DisplayNameGeneration; 294 | import org.junit.jupiter.api.DisplayNameGenerator; 295 | import org.junit.jupiter.api.Test; 296 | 297 | import static com.kousenit.OllamaRecords.*; 298 | import static org.junit.jupiter.api.Assertions.*; 299 | 300 | @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) 301 | public class OllamaServiceTest { 302 | private final OllamaService service = new OllamaService(); 303 | 304 | @Test 305 | public void testGenerate() { 306 | var ollamaRequest = new OllamaTextRequest("gemma2", "Why is the sky blue?", false); 307 | OllamaResponse ollamaResponse = service.generate(ollamaRequest); 308 | String answer = ollamaResponse.response(); 309 | System.out.println(answer); 310 | assertTrue(answer.contains("scattering")); 311 | } 312 | } 313 | ``` 314 | 315 | * Run the test to verify the service works as expected. 316 | * Add an overload of the `generate` method to `OllamaService` that takes two Strings: one for the `model` and one for the `prompt`, and returns an `OllamaResponse` object. 317 | 318 | ```java 319 | public OllamaResponse generate(String model, String prompt) { 320 | return generate(new OllamaTextRequest(model, prompt, false)); 321 | } 322 | ``` 323 | 324 | * Adding convenience methods like that to make the calls easier for a client is a common practice. Add a test to verify the new method works. 325 | 326 | ```java 327 | @Test 328 | void generate_with_model_and_name() { 329 | var ollamaResponse = service.generate("gemma2", "Why is the sky blue?"); 330 | String answer = ollamaResponse.response(); 331 | System.out.println(answer); 332 | assertTrue(answer.contains("scattering")); 333 | } 334 | ``` 335 | 336 | ## Streaming Results 337 | 338 | * To see what a streaming result looks like, add a method to send the message and return the response as a `String`: 339 | 340 | ```java 341 | public String generateStreaming(OllamaTextRequest ollamaRequest) { 342 | HttpRequest request = HttpRequest.newBuilder() 343 | .uri(URI.create(URL + "/api/generate")) 344 | .header("Content-Type", "application/json") 345 | .header("Accept", "application/json") 346 | .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(ollamaRequest))) 347 | .build(); 348 | try { 349 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 350 | return response.body(); 351 | } catch (IOException | InterruptedException e) { 352 | throw new RuntimeException(e); 353 | } 354 | } 355 | ``` 356 | 357 | * Add a test to see the streaming result: 358 | 359 | ```java 360 | @Test 361 | public void streaming_generate_request() { 362 | var request = new OllamaTextRequest("gemma2", "Why is the sky blue?", true); 363 | String response = service.generateStreaming(request); 364 | System.out.println(response); 365 | } 366 | ``` 367 | 368 | * Run the test to see the streaming result. It will be similar to: 369 | 370 | ```json lines 371 | {"model":"gemma2","created_at":"2024-07-08T20:15:46.965689Z","response":"The","done":false} 372 | {"model":"gemma2","created_at":"2024-07-08T20:15:46.991009Z","response":" sky","done":false} 373 | {"model":"gemma2","created_at":"2024-07-08T20:15:47.019408Z","response":" appears","done":false} 374 | {"model":"gemma2","created_at":"2024-07-08T20:15:47.050245Z","response":" blue","done":false} 375 | {"model":"gemma2","created_at":"2024-07-08T20:15:47.095448Z","response":" due","done":false} 376 | {"model":"gemma2","created_at":"2024-07-08T20:15:47.126318Z","response":" to","done":false} 377 | ``` 378 | 379 | followed by lots more lines, until the `done` field is `true`. 380 | 381 | ## Fix the camel case issue 382 | 383 | * The `created_at` field in the `OllamaResponse` record should be `createdAt`. Since we're already using `FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES` in our Gson configuration, the field will automatically be mapped correctly from `created_at` to `createdAt`. 384 | 385 | * Run one of the existing tests to see that the field is populated correctly. 386 | * The `Gson` object in `OllamaService` should be created using a builder with the field naming policy: 387 | 388 | ```java 389 | private final Gson gson = new GsonBuilder() 390 | .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 391 | .setPrettyPrinting() 392 | .create(); 393 | ``` 394 | 395 | * Make sure the test prints the full `OllamaResponse`, then run the test again to see that the field is populated correctly. You may also want to add an assertion that the `createdAt` field of the response is not null. 396 | 397 | ## Create a vision request 398 | 399 | * Ollama also supports vision models, like `moondream`, that can read images and generate text descriptions from them. The images must be uploaded in the form of Base 64 encoded strings. 400 | * We're going to need a _multimodal_ model that supports vision requests. A small one, useful for experiements, is called `moondream`. Download it by running the command `ollama pull moondream`. (Note this is a `pull` rather than a `run` -- we don't plan to run the vision model at the command line.) 401 | * Add a new record to `OllamaRecords` called `OllamaVisionRequest` with the following fields: 402 | - `model` of type `String` 403 | - `prompt` of type `String` 404 | - `stream` of type `boolean` 405 | - `images` of type `List` 406 | 407 | ```java 408 | public record OllamaVisionRequest( 409 | String model, 410 | String prompt, 411 | boolean stream, 412 | List images) {} 413 | ``` 414 | 415 | * Fortunately, the output response from vision models is the same as from the text models, so you can reuse the `OllamaResponse` record. 416 | * Rather than require the client to submit the image in that form, let's provide a method to convert an image from a URL into the proper string. We'll call this method inside a _compact constructor_, a cool feature of records. 417 | * Inside the `OllamaVisionRequest` record, add a compact constructor that takes a `String` path to a local image and converts it to a Base 64 encoded string. 418 | 419 | ```java 420 | public record OllamaVisionRequest( 421 | String model, 422 | String prompt, 423 | boolean stream, 424 | List images) { 425 | 426 | public OllamaVisionRequest { 427 | images = images.stream() 428 | .map(this::encodeImage) 429 | .collect(Collectors.toList()); 430 | } 431 | 432 | private String encodeImage(String path) { 433 | try { 434 | byte[] imageBytes = Files.readAllBytes(Paths.get(path)); 435 | return Base64.getEncoder().encodeToString(imageBytes); 436 | } catch (IOException e) { 437 | throw new UncheckedIOException(e); 438 | } 439 | } 440 | } 441 | ``` 442 | 443 | * Add a new method to `OllamaService` called `generateVision` that takes an `OllamaVisionRequest` object and returns an `OllamaResponse` object. 444 | * We need an image to view. Here is one if you don't already have one available: 445 | 446 | ![Cats playing cards](src/main/resources/cats_playing_cards.png) 447 | 448 | * Add the following test to the `OllamaServiceTest` class: 449 | 450 | ```java 451 | 452 | @Test 453 | void test_vision_generate() { 454 | var request = new OllamaVisionRequest("moondream", 455 | """ 456 | Generate a text description of this image 457 | suitable for accessibility in HTML. 458 | """, 459 | false, 460 | List.of("src/main/resources/cats_playing_cards.png")); 461 | OllamaResponse ollamaResponse = service.generateVision(request); 462 | assertNotNull(ollamaResponse); 463 | System.out.println(ollamaResponse.response()); 464 | } 465 | ``` 466 | 467 | * Add the `generateVision` method to the `OllamaService` class: 468 | 469 | ```java 470 | public OllamaResponse generateVision(OllamaVisionRequest visionRequest) { 471 | HttpRequest request = HttpRequest.newBuilder() 472 | .uri(URI.create(URL + "/api/generate")) 473 | .header("Content-Type", "application/json") 474 | .header("Accept", "application/json") 475 | .POST(HttpRequest.BodyPublishers.ofString( 476 | gson.toJson(visionRequest))) 477 | .build(); 478 | try { 479 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 480 | return gson.fromJson(response.body(), OllamaResponse.class); 481 | } catch (IOException | InterruptedException e) { 482 | throw new RuntimeException(e); 483 | } 484 | } 485 | ``` 486 | 487 | * The result is inside the `response` field of the `OllamaResponse` object. Run the test to see the result. 488 | 489 | * That's an awful lot of duplicated code. Surely we can do better? We can, by using a _sealed interface_. 490 | 491 | ## Refactor to sealed interfaces 492 | 493 | * Now comes the question: how do we send the vision request to the service? We'd rather not duplicate all the existing code. Fortunately, records can implement interfaces. In this case, we'll use a _sealed interface_, so that only _permitted_ classes can implement it. 494 | * Refactor our existing code by creating a sealed interface called `OllamaRequest` with two permitted classes: `OllamaTextRequest` and `OllamaVisionRequest` and add it to our `OllamaRecords` class. Add `implements OllamaRequest` to both records. 495 | * Make sure the tests for the text model still pass. 496 | 497 | ```java 498 | public class OllamaRecords { 499 | public sealed interface OllamaRequest 500 | permits OllamaTextRequest, OllamaVisionRequest { 501 | } 502 | 503 | public record OllamaTextRequest( 504 | String model, 505 | String prompt, 506 | boolean stream) implements OllamaRequest {} 507 | 508 | public record OllamaVisionRequest( 509 | String model, 510 | String prompt, 511 | boolean stream, 512 | List images) implements OllamaRequest { 513 | 514 | public OllamaVisionRequest { 515 | images = images.stream() 516 | .map(this::encodeImage) 517 | .collect(Collectors.toList()); 518 | } 519 | 520 | private String encodeImage(String path) { 521 | try { 522 | byte[] imageBytes = Files.readAllBytes(Paths.get(path)); 523 | return Base64.getEncoder() 524 | .encodeToString(imageBytes); 525 | } catch (IOException e) { 526 | throw new UncheckedIOException(e); 527 | } 528 | } 529 | } 530 | } 531 | ``` 532 | 533 | * In `OllamaService`, we can go back to when our `generate` method took an `OllamaRequest` object as its only argument and let polymorphism distinguish between a text request and a vision request. We can then use pattern matching to determine which type of request we have and act accordingly. We had this before, but let's repeat it here for convenience: 534 | 535 | ```java 536 | public OllamaResponse generate(OllamaRequest ollamaRequest) { 537 | HttpRequest request = HttpRequest.newBuilder() 538 | .uri(URI.create(URL + "/api/generate")) 539 | .header("Content-Type", "application/json") 540 | .header("Accept", "application/json") 541 | .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(ollamaRequest))) 542 | .build(); 543 | try { 544 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 545 | return gson.fromJson(response.body(), OllamaResponse.class); 546 | } catch (IOException | InterruptedException e) { 547 | throw new RuntimeException(e); 548 | } 549 | } 550 | ``` 551 | 552 | * Pattern matching for switch became GA in Java 21. If you have Java 21 available, let's use it to identify what type of request we are sending. First, add a system logger as an attribute to `OllamaService`: 553 | 554 | ```java 555 | private final System.Logger logger = System.getLogger(OllamaService.class.getName()); 556 | ``` 557 | 558 | * With that available, we can now add a switch statement to the beginning of the `generate` method: 559 | 560 | ```java 561 | public OllamaResponse generate(OllamaRequest ollamaRequest) { 562 | switch (ollamaRequest) { 563 | case OllamaTextRequest textRequest -> { 564 | logger.log(System.Logger.Level.INFO, "Text request to: {0}", textRequest.model()); 565 | logger.log(System.Logger.Level.INFO, "Prompt: {0}", textRequest.prompt()); 566 | } 567 | case OllamaVisionRequest visionRequest -> { 568 | logger.log(System.Logger.Level.INFO, "Vision request to: {0}", visionRequest.model()); 569 | logger.log(System.Logger.Level.INFO, "Size of uploaded image: {0}", visionRequest.images() 570 | .getFirst() 571 | .length()); 572 | logger.log(System.Logger.Level.INFO, "Prompt: {0}", visionRequest.prompt()); 573 | } 574 | } 575 | 576 | // ... rest as before ... 577 | } 578 | ``` 579 | 580 | * Because we used a sealed interface, there are only two possible types of requests. Our switch expression is therefore exhaustive. We can add tests for each to verify that the logging works as expected. 581 | 582 | ```java 583 | @Test 584 | void generate_with_text_request() { 585 | var ollamaRequest = new OllamaTextRequest("gemma2", "Why is the sky blue?", false); 586 | OllamaResponse ollamaResponse = service.generate(ollamaRequest); 587 | System.out.println(ollamaResponse); 588 | String answer = ollamaResponse.response(); 589 | System.out.println(answer); 590 | assertTrue(answer.contains("scattering")); 591 | assertNotNull(ollamaResponse.createdAt()); 592 | } 593 | 594 | @Test 595 | void generate_with_vision_request() { 596 | var request = new OllamaVisionRequest("moondream", 597 | """ 598 | Generate a text description of this image 599 | suitable for accessibility in HTML. 600 | """, 601 | false, 602 | List.of("src/main/resources/cats_playing_cards.png")); 603 | OllamaResponse response = service.generate(request); 604 | assertNotNull(response); 605 | System.out.println(response); 606 | } 607 | ``` 608 | 609 | * Run the tests to verify that the logging works as expected. This combination of modeling the data as records, limiting the available entries with sealed interfaces, and selecting between them with switch expressions, is a form of [data oriented programming](https://www.infoq.com/articles/data-oriented-programming-java/), as described in the linked article by Brian Goetz. 610 | 611 | ## Have a conversation 612 | 613 | * The `generate` endpoint we've been using is very limited. Ollama also supports a more complicated model, similar to those from OpenAI, Anthropic, Gemini, and Mistral. That model allows _conversations_, that connect messages together. You need to add the messages yourself in a list, alternating between `user` and `assistant` messages, and the AI tool will respond to the complete sequence. 614 | * In Ollama, you send a POST request to `/api/chat` instead of `/api/generate` when you're dealing with a list of messages rather than just a single one. 615 | * Here is a sample request (from the [Ollama documentation](https://github.com/ollama/ollama/blob/main/docs/api.md)): 616 | 617 | ```json 618 | { 619 | "model": "llama3", 620 | "messages": [ 621 | { 622 | "role": "user", 623 | "content": "why is the sky blue?" 624 | }, 625 | { 626 | "role": "assistant", 627 | "content": "due to rayleigh scattering." 628 | }, 629 | { 630 | "role": "user", 631 | "content": "how is that different than mie scattering?" 632 | } 633 | ] 634 | } 635 | ``` 636 | 637 | * Create a new record called `OllamaMessage` with the following fields: 638 | - `role` of type `String` 639 | - `content` of type `String` 640 | 641 | ```java 642 | public record OllamaMessage( 643 | String role, 644 | String content) {} 645 | ``` 646 | 647 | * The value of `role` is restricted to: 648 | - `user`, for client questions 649 | - `assistant`, for AI responses 650 | - `system`, for global context 651 | - With that in mind, add a compact constructor to `OllamaMessage` that validates the `role` field: 652 | 653 | ```java 654 | public record OllamaMessage(String role, 655 | String content) { 656 | public OllamaMessage { 657 | if (!List.of("user", "assistant", "system").contains(role)) { 658 | throw new IllegalArgumentException("Invalid role: " + role); 659 | } 660 | } 661 | } 662 | ``` 663 | 664 | * Create a new record called `OllamaChatRequest` with the following fields: 665 | - `model` of type `String` 666 | - `messages` of type `List` 667 | - 'temperature' of type `double` 668 | 669 | ```java 670 | public record OllamaChatRequest( 671 | String model, 672 | List messages, 673 | boolean stream) {} 674 | ``` 675 | 676 | 677 | * Add a record called `OllamaChatResponse` with the following fields: 678 | - `model` of type `String` 679 | - `createdAt` of type `String` 680 | - `message` of type `OllamaMessage` 681 | - `done` of type `boolean` 682 | 683 | ```java 684 | public record OllamaChatResponse( 685 | String model, 686 | String createdAt, 687 | OllamaMessage message, 688 | boolean done) {} 689 | ``` 690 | 691 | * Add a new method to `OllamaService` called `chat` that takes an `OllamaChatRequest` object and returns a `List` object. 692 | * Add a test to try out the conversation model: 693 | 694 | ```java 695 | @Test 696 | void test_chat() { 697 | var request = new OllamaChatRequest("gemma2", 698 | List.of(new OllamaMessage("user", "why is the sky blue?"), 699 | new OllamaMessage("assistant", "due to rayleigh scattering."), 700 | new OllamaMessage("user", "how is that different than mie scattering?")), 701 | false); 702 | OllamaChatResponse response = service.chat(request); 703 | assertNotNull(response); 704 | System.out.println(response); 705 | } 706 | ``` 707 | 708 | * Update the `OllamaService` class to include the `chat` method: 709 | 710 | ```java 711 | public OllamaChatResponse chat(OllamaChatRequest chatRequest) { 712 | String json = gson.toJson(chatRequest); 713 | HttpRequest request = HttpRequest.newBuilder() 714 | .uri(URI.create(URL + "/api/chat")) 715 | .header("Content-Type", "application/json") 716 | .header("Accept", "application/json") 717 | .POST(HttpRequest.BodyPublishers.ofString(json)) 718 | .build(); 719 | try { 720 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 721 | return gson.fromJson(response.body(), OllamaChatResponse.class); 722 | } catch (IOException | InterruptedException e) { 723 | throw new RuntimeException(e); 724 | } 725 | } 726 | ``` 727 | 728 | * Run the test to see it in action. In practice, you would extract the `assistant` response from each message and add it to the next request. Note that all the major framework (Spring AI, LangChain4j, etc) have ways of doing that for you. 729 | 730 | ## Generating Images 731 | 732 | * We now return to OpenAI to use it's DALL-E 3 image model. Generating images is straightforward. Again, we'll create an image request object that models the input JSON data, send it in a POST request, and process the results. It turns out we can retrieve the response as either a URL or a Base 64 encoded string. 733 | 734 | * Add records for image generation to the `OpenAiRecords` class: 735 | 736 | ```java 737 | public record ImageRequest( 738 | String model, 739 | String prompt, 740 | Integer n, 741 | String quality, 742 | String responseFormat, 743 | String size, 744 | String style 745 | ) {} 746 | ``` 747 | 748 | * The response wraps either a URL to the generated image, or the actual Base 64 encoded data. For this exercise, we'll get the URL. Add the response record: 749 | 750 | ```java 751 | public record ImageResponse( 752 | long created, 753 | List data) { 754 | public record Image( 755 | String url, 756 | String revisedPrompt) {} 757 | } 758 | ``` 759 | 760 | * Add a class called `DalleService` with constants for the endpoint, the API key, and a `Gson` object: 761 | 762 | ```java 763 | public class DalleService { 764 | private static final String IMAGE_URL = "https://api.openai.com/v1/images/generations"; 765 | private static final String API_KEY = System.getenv("OPENAI_API_KEY"); 766 | 767 | private final Gson gson = new GsonBuilder() 768 | .setPrettyPrinting() 769 | .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 770 | .create(); 771 | } 772 | ``` 773 | 774 | * Add a new method to `DalleService` called `generateImage` that takes a `DalleImageRequest` object and returns a `DalleImageResponse` object. 775 | 776 | ```java 777 | public ImageResponse generateImage(ImageRequest imageRequest) { 778 | HttpRequest request = HttpRequest.newBuilder() 779 | .uri(URI.create(IMAGE_URL)) 780 | .header("Authorization", "Bearer %s".formatted(API_KEY)) 781 | .header("Content-Type", "application/json") 782 | .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(imageRequest))) 783 | .build(); 784 | try (HttpClient client = HttpClient.newHttpClient()) { 785 | HttpResponse response = 786 | client.send(request, HttpResponse.BodyHandlers.ofString()); 787 | return gson.fromJson(response.body(), ImageResponse.class); 788 | } catch (IOException | InterruptedException e) { 789 | throw new RuntimeException("Error sending prompt prompt", e); 790 | } 791 | } 792 | ``` 793 | 794 | * Add a test to try out the image generation model: 795 | 796 | ```java 797 | import org.junit.jupiter.api.DisplayNameGeneration; 798 | import org.junit.jupiter.api.DisplayNameGenerator; 799 | import org.junit.jupiter.api.Test; 800 | 801 | import static com.kousenit.OpenAiRecords.*; 802 | 803 | @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) 804 | class DalleServiceTest { 805 | private final DalleService service = new DalleService(); 806 | 807 | @Test 808 | void generate_image() { 809 | var imageRequest = new ImageRequest( 810 | "dall-e-3", 811 | "Draw a picture of cats playing cards", 812 | 1, 813 | "standard", 814 | "url", 815 | "1024x1024", 816 | "natural"); 817 | ImageResponse imageResponse = service.generateImage(imageRequest); 818 | System.out.println(imageResponse.data().getFirst().revisedPrompt()); 819 | System.out.println(imageResponse.data().getFirst().url()); 820 | } 821 | } 822 | ``` 823 | 824 | * Run the test to see it in action. The response will contain a URL to the generated image, with you can either click on or copy and paste into a browser to download the image. 825 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "AiJavaLabs" 2 | -------------------------------------------------------------------------------- /src/main/java/com/kousenit/DalleService.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import static com.kousenit.OpenAiRecords.*; 4 | 5 | import com.google.gson.FieldNamingPolicy; 6 | import com.google.gson.Gson; 7 | import com.google.gson.GsonBuilder; 8 | import java.io.IOException; 9 | import java.net.URI; 10 | import java.net.http.HttpClient; 11 | import java.net.http.HttpRequest; 12 | import java.net.http.HttpResponse; 13 | 14 | public class DalleService { 15 | private static final String IMAGE_URL = "https://api.openai.com/v1/images/generations"; 16 | private static final String API_KEY = System.getenv("OPENAI_API_KEY"); 17 | 18 | private final Gson gson = new GsonBuilder() 19 | .setPrettyPrinting() 20 | .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 21 | .create(); 22 | 23 | public ImageResponse generateImage(ImageRequest imageRequest) { 24 | HttpRequest request = HttpRequest.newBuilder() 25 | .uri(URI.create(IMAGE_URL)) 26 | .header("Authorization", "Bearer %s".formatted(API_KEY)) 27 | .header("Content-Type", "application/json") 28 | .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(imageRequest))) 29 | .build(); 30 | try (HttpClient client = HttpClient.newHttpClient()) { 31 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 32 | return gson.fromJson(response.body(), ImageResponse.class); 33 | } catch (IOException | InterruptedException e) { 34 | throw new RuntimeException("Error sending prompt prompt", e); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/kousenit/EasyRAGDemo.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import static com.kousenit.Utils.*; 4 | import static dev.langchain4j.data.document.loader.FileSystemDocumentLoader.loadDocuments; 5 | 6 | import dev.langchain4j.data.document.Document; 7 | import dev.langchain4j.data.segment.TextSegment; 8 | import dev.langchain4j.memory.chat.MessageWindowChatMemory; 9 | import dev.langchain4j.model.chat.ChatModel; 10 | import dev.langchain4j.model.openai.OpenAiChatModel; 11 | import dev.langchain4j.rag.content.retriever.ContentRetriever; 12 | import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; 13 | import dev.langchain4j.service.AiServices; 14 | import dev.langchain4j.store.embedding.EmbeddingStoreIngestor; 15 | import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; 16 | import java.util.List; 17 | 18 | public class EasyRAGDemo { 19 | public interface Assistant { 20 | String chat(String userMessage); 21 | } 22 | 23 | private static ContentRetriever createContentRetriever(List documents) { 24 | // Here, we create and empty in-memory store for our documents and their embeddings. 25 | InMemoryEmbeddingStore embeddingStore = new InMemoryEmbeddingStore<>(); 26 | 27 | // Here, we are ingesting our documents into the store. 28 | // Under the hood, a lot of "magic" is happening, but we can ignore it for now. 29 | EmbeddingStoreIngestor.ingest(documents, embeddingStore); 30 | 31 | // Lastly, let's create a content retriever from an embedding store. 32 | return EmbeddingStoreContentRetriever.from(embeddingStore); 33 | } 34 | 35 | public static void main(String[] args) { 36 | List documents = loadDocuments(toPath("documents/"), glob("*.txt")); 37 | 38 | ChatModel chatLanguageModel = OpenAiChatModel.builder() 39 | .apiKey(System.getenv("OPENAI_API_KEY")) 40 | .modelName("gpt-4o-mini") 41 | .build(); 42 | 43 | // Second, let's create an assistant that will have access to our documents 44 | Assistant assistant = AiServices.builder(Assistant.class) 45 | .chatModel(chatLanguageModel) 46 | .chatMemory(MessageWindowChatMemory.withMaxMessages(10)) 47 | .contentRetriever(createContentRetriever(documents)) 48 | .build(); 49 | 50 | // Lastly, let's start the conversation with the assistant. 51 | startConversationWith(assistant); 52 | System.exit(0); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/com/kousenit/OllamaRecords.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import java.io.IOException; 4 | import java.io.UncheckedIOException; 5 | import java.nio.file.Files; 6 | import java.nio.file.Paths; 7 | import java.util.Base64; 8 | import java.util.List; 9 | import java.util.stream.Collectors; 10 | 11 | public class OllamaRecords { 12 | 13 | public sealed interface OllamaRequest permits OllamaTextRequest, OllamaVisionRequest {} 14 | 15 | public record OllamaTextRequest(String model, String prompt, boolean stream) implements OllamaRequest {} 16 | 17 | public record OllamaVisionRequest(String model, String prompt, boolean stream, List images) 18 | implements OllamaRequest { 19 | 20 | public OllamaVisionRequest { 21 | images = images.stream().map(this::encodeImage).collect(Collectors.toList()); 22 | } 23 | 24 | private String encodeImage(String path) { 25 | try { 26 | byte[] imageBytes = Files.readAllBytes(Paths.get(path)); 27 | return Base64.getEncoder().encodeToString(imageBytes); 28 | } catch (IOException e) { 29 | throw new UncheckedIOException(e); 30 | } 31 | } 32 | } 33 | 34 | public record OllamaResponse(String model, String createdAt, String response, boolean done) {} 35 | 36 | public record OllamaMessage(String role, String content) { 37 | 38 | public OllamaMessage { 39 | if (!List.of("user", "assistant", "system").contains(role)) { 40 | throw new IllegalArgumentException("Invalid role: " + role); 41 | } 42 | } 43 | } 44 | 45 | public record OllamaChatRequest(String model, List messages, boolean stream) {} 46 | 47 | public record OllamaChatResponse(String model, String createdAt, OllamaMessage message, boolean done) {} 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/kousenit/OllamaService.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import static com.kousenit.OllamaRecords.*; 4 | 5 | import com.google.gson.FieldNamingPolicy; 6 | import com.google.gson.Gson; 7 | import com.google.gson.GsonBuilder; 8 | import java.io.IOException; 9 | import java.net.URI; 10 | import java.net.http.HttpClient; 11 | import java.net.http.HttpRequest; 12 | import java.net.http.HttpResponse; 13 | import java.util.stream.Stream; 14 | 15 | public class OllamaService { 16 | private static final HttpClient client = HttpClient.newHttpClient(); 17 | private static final String URL = "http://localhost:11434"; 18 | 19 | private final System.Logger logger = System.getLogger(OllamaService.class.getName()); 20 | 21 | private final Gson gson = new GsonBuilder() 22 | .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 23 | .setPrettyPrinting() 24 | .create(); 25 | 26 | public OllamaResponse generate(OllamaRequest ollamaRequest) { 27 | switch (ollamaRequest) { 28 | case OllamaTextRequest textRequest -> { 29 | logger.log(System.Logger.Level.INFO, "Text request to: {0}", textRequest.model()); 30 | logger.log(System.Logger.Level.INFO, "Prompt: {0}", textRequest.prompt()); 31 | } 32 | case OllamaVisionRequest visionRequest -> { 33 | logger.log(System.Logger.Level.INFO, "Vision request to: {0}", visionRequest.model()); 34 | logger.log( 35 | System.Logger.Level.INFO, 36 | "Size of uploaded image: {0}", 37 | visionRequest.images().getFirst().length()); 38 | logger.log(System.Logger.Level.INFO, "Prompt: {0}", visionRequest.prompt()); 39 | } 40 | } 41 | 42 | HttpRequest request = HttpRequest.newBuilder() 43 | .uri(URI.create(URL + "/api/generate")) 44 | .header("Content-Type", "application/json") 45 | .header("Accept", "application/json") 46 | .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(ollamaRequest))) 47 | .build(); 48 | try { 49 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 50 | logger.log(System.Logger.Level.INFO, "Response: {0}", response.body()); 51 | return gson.fromJson(response.body(), OllamaResponse.class); 52 | } catch (IOException | InterruptedException e) { 53 | throw new RuntimeException(e); 54 | } 55 | } 56 | 57 | public OllamaResponse generate(String model, String input) { 58 | return generate(new OllamaTextRequest(model, input, false)); 59 | } 60 | 61 | public String generateStreaming(OllamaTextRequest ollamaTextRequest) { 62 | logger.log(System.Logger.Level.INFO, "Generating streaming response with request: {0}", ollamaTextRequest); 63 | HttpRequest request = HttpRequest.newBuilder() 64 | .uri(URI.create(URL + "/api/generate")) 65 | .header("Content-Type", "application/json") 66 | .header("Accept", "application/json") 67 | .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(ollamaTextRequest))) 68 | .build(); 69 | try { 70 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 71 | logger.log(System.Logger.Level.DEBUG, "Response: {0}", response.body()); 72 | return response.body(); 73 | } catch (IOException | InterruptedException e) { 74 | throw new RuntimeException(e); 75 | } 76 | } 77 | 78 | public OllamaResponse generateVision(OllamaVisionRequest visionRequest) { 79 | logger.log(System.Logger.Level.INFO, "Model: {0}", visionRequest.model()); 80 | logger.log( 81 | System.Logger.Level.INFO, 82 | "Size of uploaded image: {0}", 83 | visionRequest.images().getFirst().length()); 84 | logger.log(System.Logger.Level.INFO, "Prompt: {0}", visionRequest.prompt()); 85 | HttpRequest request = HttpRequest.newBuilder() 86 | .uri(URI.create(URL + "/api/generate")) 87 | .header("Content-Type", "application/json") 88 | .header("Accept", "application/json") 89 | .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(visionRequest))) 90 | .build(); 91 | try { 92 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 93 | return gson.fromJson(response.body(), OllamaResponse.class); 94 | } catch (IOException | InterruptedException e) { 95 | throw new RuntimeException(e); 96 | } 97 | } 98 | 99 | public OllamaChatResponse chat(OllamaChatRequest chatRequest) { 100 | String json = gson.toJson(chatRequest); 101 | HttpRequest request = HttpRequest.newBuilder() 102 | .uri(URI.create(URL + "/api/chat")) 103 | .header("Content-Type", "application/json") 104 | .header("Accept", "application/json") 105 | .POST(HttpRequest.BodyPublishers.ofString(json)) 106 | .build(); 107 | try { 108 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 109 | return gson.fromJson(response.body(), OllamaChatResponse.class); 110 | } catch (IOException | InterruptedException e) { 111 | throw new RuntimeException(e); 112 | } 113 | } 114 | 115 | public String generateStreamingResponse(OllamaTextRequest ollamaRequest) { 116 | try (var client = HttpClient.newHttpClient()) { 117 | var requestBody = gson.toJson(ollamaRequest); 118 | var httpRequest = HttpRequest.newBuilder() 119 | .uri(URI.create(URL + "/api/generate")) 120 | .header("Content-Type", "application/json") 121 | .POST(HttpRequest.BodyPublishers.ofString(requestBody)) 122 | .build(); 123 | 124 | var responseFuture = client.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofLines()) 125 | .thenApply(this::handleLines); 126 | 127 | return responseFuture.join(); 128 | } catch (Exception e) { 129 | System.err.println("Error generating streaming response: " + e.getMessage()); 130 | return ""; 131 | } 132 | } 133 | 134 | private String handleLines(HttpResponse> response) { 135 | var accumulatedResponse = new StringBuilder(); 136 | response.body().forEach(line -> { 137 | try { 138 | var ollamaResponse = gson.fromJson(line, OllamaResponse.class); 139 | System.out.print(ollamaResponse.response()); 140 | accumulatedResponse.append(ollamaResponse.response()); 141 | 142 | if (ollamaResponse.done()) { 143 | System.out.println(); 144 | System.out.println("Final response metadata: " + line); 145 | } 146 | } catch (Exception e) { 147 | System.err.println("Error processing JSON: " + e.getMessage()); 148 | } 149 | }); 150 | return accumulatedResponse.toString(); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/main/java/com/kousenit/OpenAiRecords.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import com.google.gson.annotations.SerializedName; 4 | import java.util.List; 5 | import java.util.Map; 6 | 7 | public class OpenAiRecords { 8 | // Listing the models 9 | public record ModelList(List data) { 10 | public record Model(String id, long created, @SerializedName("owned_by") String ownedBy) {} 11 | } 12 | 13 | // Generating images 14 | public record ImageRequest( 15 | String model, String prompt, Integer n, String quality, String responseFormat, String size, String style) {} 16 | 17 | public record ImageResponse(long created, List data) { 18 | public record Image(String url, String revisedPrompt) {} 19 | } 20 | 21 | // Vector stores 22 | public record VectorStoreList( 23 | @SerializedName("object") String object, 24 | @SerializedName("data") List data, 25 | @SerializedName("first_id") String firstId, 26 | @SerializedName("last_id") String lastId, 27 | @SerializedName("has_more") boolean hasMore) {} 28 | 29 | public record VectorStore( 30 | @SerializedName("id") String id, 31 | @SerializedName("object") String object, 32 | @SerializedName("name") String name, 33 | @SerializedName("status") String status, 34 | @SerializedName("usage_bytes") long usageBytes, 35 | @SerializedName("created_at") long createdAt, 36 | @SerializedName("file_counts") FileCounts fileCounts, 37 | @SerializedName("metadata") Map metadata, 38 | @SerializedName("expires_after") Object expiresAfter, 39 | @SerializedName("expires_at") Object expiresAt, 40 | @SerializedName("last_active_at") long lastActiveAt) {} 41 | 42 | public record FileCounts( 43 | @SerializedName("in_progress") int inProgress, 44 | @SerializedName("completed") int completed, 45 | @SerializedName("failed") int failed, 46 | @SerializedName("cancelled") int cancelled, 47 | @SerializedName("total") int total) {} 48 | } 49 | -------------------------------------------------------------------------------- /src/main/java/com/kousenit/OpenAiService.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import static com.kousenit.OpenAiRecords.*; 4 | import static com.kousenit.OpenAiRecords.ModelList; 5 | 6 | import com.google.gson.FieldNamingPolicy; 7 | import com.google.gson.Gson; 8 | import com.google.gson.GsonBuilder; 9 | import java.io.IOException; 10 | import java.net.URI; 11 | import java.net.http.HttpClient; 12 | import java.net.http.HttpRequest; 13 | import java.net.http.HttpResponse; 14 | 15 | public class OpenAiService { 16 | private static final String API_KEY = System.getenv("OPENAI_API_KEY"); 17 | private static final String MODELS_URL = "https://api.openai.com/v1/models"; 18 | 19 | private final Gson gson = new GsonBuilder() 20 | .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 21 | .create(); 22 | 23 | public ModelList listModels() { 24 | HttpRequest request = HttpRequest.newBuilder() 25 | .uri(URI.create(MODELS_URL)) 26 | .header("Authorization", "Bearer %s".formatted(API_KEY)) 27 | .header("Accept", "application/json") 28 | .build(); 29 | try (var client = HttpClient.newHttpClient()) { 30 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 31 | return gson.fromJson(response.body(), ModelList.class); 32 | } catch (IOException | InterruptedException e) { 33 | throw new RuntimeException(e); 34 | } 35 | } 36 | 37 | public VectorStoreList listVectorStores() { 38 | HttpRequest request = HttpRequest.newBuilder() 39 | .uri(URI.create("https://api.openai.com/v1/vector_stores")) 40 | .header("Authorization", "Bearer %s".formatted(API_KEY)) 41 | .header("OpenAI-Beta", "assistants=v2") 42 | .header("Accept", "application/json") 43 | .build(); 44 | try (var client = HttpClient.newHttpClient()) { 45 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 46 | return gson.fromJson(response.body(), VectorStoreList.class); 47 | } catch (IOException | InterruptedException e) { 48 | throw new RuntimeException(e); 49 | } 50 | } 51 | 52 | public void deleteVectorStore(String id) { 53 | HttpRequest request = HttpRequest.newBuilder() 54 | .uri(URI.create("https://api.openai.com/v1/vector_stores/%s".formatted(id))) 55 | .header("Authorization", "Bearer %s".formatted(API_KEY)) 56 | .header("OpenAI-Beta", "assistants=v2") 57 | .DELETE() 58 | .build(); 59 | try (var client = HttpClient.newHttpClient()) { 60 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 61 | System.out.println(response.body()); 62 | } catch (IOException | InterruptedException e) { 63 | throw new RuntimeException(e); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/com/kousenit/TextToSpeechService.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import java.io.IOException; 4 | import java.net.URI; 5 | import java.net.http.HttpClient; 6 | import java.net.http.HttpRequest; 7 | import java.net.http.HttpResponse; 8 | import java.nio.file.Path; 9 | import java.nio.file.Paths; 10 | import java.time.LocalDateTime; 11 | import java.time.format.DateTimeFormatter; 12 | 13 | public class TextToSpeechService { 14 | private static final String OPENAI_API_KEY = System.getenv("OPENAI_API_KEY"); 15 | public static final String RESOURCES_DIR = "src/main/resources"; 16 | 17 | public Path generateMp3(String model, String input, String voice) { 18 | String payload = 19 | """ 20 | { 21 | "model": "%s", 22 | "input": "%s", 23 | "voice": "%s" 24 | } 25 | """ 26 | .formatted(model, input.replaceAll("\\s+", " ").trim(), voice); 27 | 28 | HttpRequest request = HttpRequest.newBuilder() 29 | .uri(URI.create("https://api.openai.com/v1/audio/speech")) 30 | .header("Authorization", "Bearer %s".formatted(OPENAI_API_KEY)) 31 | .header("Content-Type", "application/json") 32 | .header("Accept", "audio/mpeg") 33 | .POST(HttpRequest.BodyPublishers.ofString(payload)) 34 | .build(); 35 | 36 | Path filePath = getFilePath(); 37 | try (HttpClient client = HttpClient.newHttpClient()) { 38 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(filePath)); 39 | return response.body(); 40 | } catch (IOException | InterruptedException e) { 41 | throw new RuntimeException(e); 42 | } 43 | } 44 | 45 | private Path getFilePath() { 46 | String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); 47 | String fileName = String.format("audio_%s.png", timestamp); 48 | Path dir = Paths.get(RESOURCES_DIR); 49 | return dir.resolve(fileName); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/com/kousenit/Utils.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import java.net.URISyntaxException; 4 | import java.net.URL; 5 | import java.nio.file.FileSystems; 6 | import java.nio.file.Path; 7 | import java.nio.file.PathMatcher; 8 | import java.nio.file.Paths; 9 | import java.util.Scanner; 10 | import org.slf4j.Logger; 11 | import org.slf4j.LoggerFactory; 12 | 13 | public class Utils { 14 | public static PathMatcher glob(String glob) { 15 | return FileSystems.getDefault().getPathMatcher("glob:" + glob); 16 | } 17 | 18 | public static Path toPath(String relativePath) { 19 | try { 20 | URL fileUrl = EasyRAGDemo.class.getClassLoader().getResource(relativePath); 21 | assert fileUrl != null; 22 | return Paths.get(fileUrl.toURI()); 23 | } catch (URISyntaxException e) { 24 | throw new RuntimeException(e); 25 | } 26 | } 27 | 28 | @SuppressWarnings("LoggingSimilarMessage") 29 | public static void startConversationWith(EasyRAGDemo.Assistant assistant) { 30 | Logger log = LoggerFactory.getLogger(EasyRAGDemo.Assistant.class); 31 | try (Scanner scanner = new Scanner(System.in)) { 32 | while (true) { 33 | log.info("=================================================="); 34 | log.info("User: "); 35 | String userQuery = scanner.nextLine(); 36 | log.info("=================================================="); 37 | 38 | if ("exit".equalsIgnoreCase(userQuery)) { 39 | break; 40 | } 41 | 42 | String agentAnswer = assistant.chat(userQuery); 43 | log.info("=================================================="); 44 | log.info("Assistant: {}", agentAnswer); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/resources/cats_playing_cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kousen/AiJavaLabs/35ea906c10b0608d7ba73d40aefdbb48de870325/src/main/resources/cats_playing_cards.png -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %d{yyyy-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/test/java/com/kousenit/DalleServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import static com.kousenit.OpenAiRecords.*; 4 | 5 | import org.junit.jupiter.api.DisplayNameGeneration; 6 | import org.junit.jupiter.api.DisplayNameGenerator; 7 | import org.junit.jupiter.api.Test; 8 | 9 | @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) 10 | class DalleServiceTest { 11 | private final DalleService service = new DalleService(); 12 | 13 | @Test 14 | void generate_image() { 15 | var imageRequest = new ImageRequest( 16 | "dall-e-3", "Generate a picture of cats playing cards", 1, "standard", "url", "1024x1024", "natural"); 17 | ImageResponse imageResponse = service.generateImage(imageRequest); 18 | System.out.println(imageResponse.data().getFirst().revisedPrompt()); 19 | System.out.println(imageResponse.data().getFirst().url()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/test/java/com/kousenit/OllamaLC4jTest.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import dev.langchain4j.model.chat.ChatModel; 4 | import dev.langchain4j.model.chat.response.ChatResponse; 5 | import dev.langchain4j.model.chat.response.StreamingChatResponseHandler; 6 | import dev.langchain4j.model.ollama.OllamaChatModel; 7 | import dev.langchain4j.model.ollama.OllamaStreamingChatModel; 8 | import org.junit.jupiter.api.Test; 9 | 10 | import java.util.concurrent.CompletableFuture; 11 | 12 | public class OllamaLC4jTest { 13 | 14 | @Test 15 | void testOllama() { 16 | ChatModel model = OllamaChatModel.builder() 17 | .baseUrl("http://localhost:11434") 18 | .modelName("gemma") 19 | .build(); 20 | System.out.println(model.chat("Why is the sky blue?")); 21 | } 22 | 23 | @Test 24 | void testOllamaStreaming() { 25 | var gemma = OllamaStreamingChatModel.builder() 26 | .baseUrl("http://localhost:11434") 27 | .modelName("gemma") 28 | .build(); 29 | 30 | // Use CompletableFuture to handle the asynchronous response 31 | var futureResponse = new CompletableFuture<>(); 32 | 33 | gemma.chat("Why is the sky blue?", new StreamingChatResponseHandler() { 34 | @Override 35 | public void onPartialResponse(String token) { 36 | System.out.print(token); 37 | } 38 | 39 | @Override 40 | public void onCompleteResponse(ChatResponse response) { 41 | futureResponse.complete(response); 42 | } 43 | 44 | @Override 45 | public void onError(Throwable error) { 46 | futureResponse.completeExceptionally(error); 47 | } 48 | }); 49 | futureResponse.join(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/test/java/com/kousenit/OllamaServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import static com.kousenit.OllamaRecords.*; 4 | import static org.assertj.core.api.Assertions.assertThat; 5 | import static org.junit.jupiter.api.Assertions.assertNotNull; 6 | import static org.junit.jupiter.api.Assertions.assertTrue; 7 | 8 | import java.util.List; 9 | import org.junit.jupiter.api.DisplayNameGeneration; 10 | import org.junit.jupiter.api.DisplayNameGenerator; 11 | import org.junit.jupiter.api.Test; 12 | 13 | @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) 14 | class OllamaServiceTest { 15 | private final OllamaService service = new OllamaService(); 16 | 17 | @Test 18 | void generate_with_text_request() { 19 | var ollamaRequest = new OllamaTextRequest("gemma2", "Why is the sky blue?", false); 20 | OllamaResponse ollamaResponse = service.generate(ollamaRequest); 21 | System.out.println(ollamaResponse); 22 | String answer = ollamaResponse.response(); 23 | System.out.println(answer); 24 | assertTrue(answer.contains("scattering")); 25 | assertNotNull(ollamaResponse.createdAt()); 26 | } 27 | 28 | @Test 29 | void generate_with_vision_request() { 30 | var request = new OllamaVisionRequest( 31 | "moondream", 32 | """ 33 | Generate a text description of this image 34 | suitable for accessibility in HTML. 35 | """, 36 | false, 37 | List.of("src/main/resources/cats_playing_cards.png")); 38 | OllamaResponse response = service.generateVision(request); 39 | assertNotNull(response); 40 | System.out.println(response); 41 | } 42 | 43 | @Test 44 | void generate_with_model_and_name() { 45 | var ollamaResponse = service.generate("gemma2", "Why is the sky blue?"); 46 | String answer = ollamaResponse.response(); 47 | System.out.println(answer); 48 | assertTrue(answer.contains("scattering")); 49 | } 50 | 51 | @Test 52 | public void streaming_generate_request() { 53 | var request = new OllamaTextRequest("gemma2", "Why is the sky blue?", true); 54 | String response = service.generateStreaming(request); 55 | System.out.println(response); 56 | } 57 | 58 | @Test 59 | void test_vision_generate() { 60 | var request = new OllamaVisionRequest( 61 | "moondream", 62 | """ 63 | Generate a text description of this image 64 | suitable for accessibility in HTML. 65 | """, 66 | false, 67 | List.of("src/main/resources/cats_playing_cards.png")); 68 | OllamaResponse response = service.generateVision(request); 69 | assertNotNull(response); 70 | System.out.println(response); 71 | } 72 | 73 | @Test 74 | void test_chat() { 75 | var request = new OllamaChatRequest( 76 | "gemma2", 77 | List.of( 78 | new OllamaMessage("user", "why is the sky blue?"), 79 | new OllamaMessage("assistant", "due to rayleigh scattering."), 80 | new OllamaMessage("user", "how is that different than mie scattering?")), 81 | false); 82 | OllamaChatResponse response = service.chat(request); 83 | assertNotNull(response); 84 | System.out.println(response); 85 | } 86 | 87 | @Test 88 | void testStreamingRequest() { 89 | var request = new OllamaTextRequest("gemma2", "Why is the sky blue?", true); 90 | var response = service.generateStreamingResponse(request); 91 | assertThat(response).isNotEmpty().containsIgnoringCase("sky").containsIgnoringCase("blue"); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/test/java/com/kousenit/OpenAiLC4jTest.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import dev.langchain4j.data.image.Image; 4 | import dev.langchain4j.model.chat.ChatModel; 5 | import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; 6 | import dev.langchain4j.model.image.ImageModel; 7 | import dev.langchain4j.model.openai.OpenAiChatModel; 8 | import dev.langchain4j.model.openai.OpenAiChatModelName; 9 | import dev.langchain4j.model.openai.OpenAiImageModel; 10 | import dev.langchain4j.model.openai.OpenAiImageModelName; 11 | import dev.langchain4j.model.output.Response; 12 | import org.junit.jupiter.api.Test; 13 | 14 | public class OpenAiLC4jTest { 15 | 16 | private final ChatModel model = OpenAiChatModel.builder() 17 | .apiKey(System.getenv("OPENAI_API_KEY")) 18 | .modelName(OpenAiChatModelName.GPT_4_O_MINI) 19 | .build(); 20 | 21 | @Test 22 | void openai() { 23 | System.out.println(model.chat("When did your training data end?")); 24 | } 25 | 26 | @Test 27 | void perplexity_via_openai() { 28 | ChatModel perplexity = OpenAiChatModel.builder() 29 | .apiKey(System.getenv("PERPLEXITY_API_KEY")) 30 | .baseUrl("https://api.perplexity.ai") 31 | .modelName("llama-3.1-sonar-small-128k-online") 32 | .logRequests(true) 33 | .logResponses(true) 34 | .build(); 35 | 36 | System.out.println( 37 | perplexity.chat( 38 | """ 39 | What are today's top news stories 40 | in the AI field? 41 | """)); 42 | } 43 | 44 | @Test 45 | void gemini_via_openai() { 46 | ChatModel gemini = OpenAiChatModel.builder() 47 | .baseUrl("https://generativelanguage.googleapis.com/v1beta/openai/") 48 | .apiKey(System.getenv("GOOGLEAI_API_KEY")) 49 | .modelName("gemini-2.0-flash-exp") 50 | .logRequests(true) 51 | .logResponses(true) 52 | .build(); 53 | 54 | System.out.println( 55 | gemini.chat( 56 | """ 57 | What are today's top news stories 58 | in the AI field? 59 | """)); 60 | } 61 | 62 | @Test 63 | void gemini_from_lc4j() { 64 | ChatModel gemini = GoogleAiGeminiChatModel.builder() 65 | .apiKey(System.getenv("GOOGLEAI_API_KEY")) 66 | .modelName("gemini-2.0-flash-exp") 67 | .build(); 68 | 69 | System.out.println( 70 | gemini.chat( 71 | """ 72 | What are today's top news stories 73 | in the AI field? 74 | """)); 75 | } 76 | 77 | @Test 78 | void generateImage() { 79 | ImageModel model = OpenAiImageModel.builder() 80 | .apiKey(System.getenv("OPENAI_API_KEY")) 81 | .modelName(OpenAiImageModelName.DALL_E_3) 82 | .build(); 83 | 84 | Response response = model.generate( 85 | """ 86 | A warrior cat rides a 87 | dragon into battle. 88 | """); 89 | System.out.println(response); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/test/java/com/kousenit/OpenAiServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import static com.kousenit.OpenAiRecords.*; 4 | import static com.kousenit.OpenAiRecords.ModelList; 5 | import static org.junit.jupiter.api.Assertions.assertTrue; 6 | 7 | import java.util.HashSet; 8 | import java.util.List; 9 | import org.junit.jupiter.api.Test; 10 | 11 | class OpenAiServiceTest { 12 | private final OpenAiService service = new OpenAiService(); 13 | 14 | @Test 15 | void listModels() { 16 | List models = service.listModels().data().stream() 17 | .map(ModelList.Model::id) 18 | .sorted() 19 | .toList(); 20 | 21 | models.forEach(System.out::println); 22 | assertTrue(new HashSet<>(models) 23 | .containsAll(List.of("dall-e-3", "gpt-3.5-turbo", "gpt-4o", "tts-1", "whisper-1"))); 24 | } 25 | 26 | @Test 27 | void list_and_delete_VectorStores() { 28 | VectorStoreList vectorStoreList = service.listVectorStores(); 29 | vectorStoreList.data().stream() 30 | .peek(System.out::println) 31 | .map(VectorStore::id) 32 | .forEach(service::deleteVectorStore); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/java/com/kousenit/TextToSpeechServiceTest.java: -------------------------------------------------------------------------------- 1 | package com.kousenit; 2 | 3 | import static org.junit.jupiter.api.Assertions.assertNotNull; 4 | 5 | import java.nio.file.Path; 6 | import org.junit.jupiter.api.Test; 7 | 8 | class TextToSpeechServiceTest { 9 | @Test 10 | void generateMp3() { 11 | var service = new TextToSpeechService(); 12 | Path result = service.generateMp3( 13 | "tts-1", 14 | """ 15 | This is an attempt to generate an audio file 16 | from text using the OpenAI API, and 17 | write it to a file directly. 18 | """, 19 | "alloy"); 20 | 21 | assertNotNull(result); 22 | System.out.println("Generated audio file: " + result.toAbsolutePath()); 23 | } 24 | } 25 | --------------------------------------------------------------------------------