├── .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 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
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 | 
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 |
--------------------------------------------------------------------------------