diff --git a/docs/05_lab_openai/05_openai.md b/docs/05_lab_openai/05_openai.md index b3e13e4..e3cad92 100644 --- a/docs/05_lab_openai/05_openai.md +++ b/docs/05_lab_openai/05_openai.md @@ -69,12 +69,12 @@ We utilize the `Github Copilot Chat` extension in VSCode to help us to initial t mkdir spring-petclinic-chat-service curl https://start.spring.io/starter.tgz \ - -d dependencies=web,cloud-eureka,cloud-config-client,actuator,lombok,spring-ai-azure-openai \ - -d bootVersion=3.3.6 -d name=chat-service -d type=maven-project \ - -d jvmVersion=17 -d language=java -d packaging=jar \ - -d groupId=org.springframework.samples.petclinic -d artifactId=chat-service \ - -d description="Spring Petclinic Chat Service" \ - | tar -xzvf - -C spring-petclinic-chat-service + -d dependencies=web,cloud-eureka,cloud-config-client,actuator,lombok,spring-ai-azure-openai \ + -d bootVersion=3.3.6 -d name=chat-service -d type=maven-project \ + -d jvmVersion=17 -d language=java -d packaging=jar \ + -d groupId=org.springframework.samples.petclinic -d artifactId=chat-service \ + -d description="Spring Petclinic Chat Service" \ + | tar -xzvf - -C spring-petclinic-chat-service ``` We may open the new application in VSCode to next operations. @@ -98,7 +98,8 @@ We utilize the `Github Copilot Chat` extension in VSCode to help us to initial t Here we use the latest test code from Spring AI as part of the prompt. Download the latest chat client sample to your local environment: ```bash - wget https://raw.githubusercontent.com/spring-projects/spring-ai/refs/heads/main/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureOpenAiChatClientIT.java -P spring-petclinic-chat-service/src/main/resources/ + IT_FILE="https://raw.githubusercontent.com/spring-projects/spring-ai/refs/heads/1.0.0-M4/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureOpenAiChatClientIT.java" + wget $IT_FILE -P spring-petclinic-chat-service/src/main/resources/ ``` Open the `Github Copilot Chat` window and drag the downloaded file into the chat window. And input the prompt: @@ -221,17 +222,17 @@ We utilize the `Github Copilot Chat` extension in VSCode to help us to initial t @Data public class Owner implements Serializable { - private Integer id; + private Integer id; - private String firstName; + private String firstName; - private String lastName; + private String lastName; - private String address; + private String address; - private String city; + private String city; - private String telephone; + private String telephone; } ``` @@ -303,7 +304,7 @@ We utilize the `Github Copilot Chat` extension in VSCode to help us to initial t ```bash az containerapp update --name $APP_NAME --resource-group $RESOURCE_GROUP \ - --source ./spring-petclinic-$APP_NAME + --source ./spring-petclinic-$APP_NAME ``` - Verify the new RAG empowered AI bot about owners. diff --git a/docs/05_lab_openai/05_openai_existing.md b/docs/05_lab_openai/05_openai_existing.md new file mode 100644 index 0000000..4cb17a0 --- /dev/null +++ b/docs/05_lab_openai/05_openai_existing.md @@ -0,0 +1,336 @@ +--- +title: 'Integrate AI to existing project' +layout: default +nav_order: 4 +parent: 'Lab 5: Integrate with Azure OpenAI' +--- + +# Integrate AI into your existing project + +In this chapter, we will learn how to create AI Java applications using Azure OpenAI and Spring AI. + +Start from a simple spring boot application, we will add AI components to the project and leverage AI to integrate with existing the petclinic solution we deployed in this lab. + +# Step by step guide + +1. We have simple sample saved in the tools directory, copy it under the folder `spring-petclinic-microservices`: + + ```bash + cp -r ../tools/spring-petclinic-chat-service . + ``` + +1. Open the chat-service project in VSCode + + ```bash + code spring-petclinic-chat-service + ``` + +1. In the VSCode IDE, make sure you have extension `Github Copilot Chat` installed, and login to Copilot with your github account. + + Open the Github Copilot Chat Window, you can ask copliot in the "Ask Copilot" input box. + +1. Create a file for your system prompt. + + Create file `src/main/resources/prompts/system-message.st` with content + + ```text + You are a chatbot good at telling jokes. + ``` + +1. Ask Copilot to create code for open AI integration. + + Click the file `src/main/resources/AzureOpenAiChatClientIT.java`, and input the following prompt to "Ask Copilot": + + ```text + Refer to the sample file named "AzureOpenAIChatClientIT.java", add a new ChatController with POST endpoint at '/chatclient' to the chat-service project: + * Use ChatClient to do the chat completion with Azure OpenAI Endpoint + * Use User Prompt from file "resources/prompts/system-message.st", do not use method getPath() + * The input of the endpoint '/chatclient' is a string from the request + * The output of the endpoint '/chatclient' is a string returned by openAI + * Use a configure file "ChatConfigure.java" file to init OpenAI ChatClient with Azure OpenAI Endpoint and API Key + ``` + + You will see output like: + + ![lab 5 copilot generate code 1](../../images/copilot-gen-code-1.png) + + ![lab 5 copilot generate code 2](../../images/copilot-gen-code-2.png) + +1. Follow the steps to make changes to the project. + + In the VSCode terminal (current directory `spring-petclinic-chat-service`), run maven command to build the project: + + ```bash + mvn clean package + ``` + + Fix the erorrs if there are any. + +1. Verify the chat endpoint. + + - In the VSCode terminal, set the endpoint and api-key of your open ai instance, and run the app: + + ```bash + export AZURE_OPENAI_API_KEY="" + export AZURE_OPENAI_ENDPOINT="" + + mvn spring-boot:run + ``` + + Ignore the warnings on `c.n.discovery.InstanceInfoReplicator`. + + - In your commandline environment, verify the '/chatclient' endpoint with simple command: + + ```bash + curl -XPOST http://localhost:8080/chatclient -d 'Hi, tell a joke' + ``` + + You will get a joke generated by OpenAI. Congratulations! + +1. Deploy the `chat-service` to your Container Apps Environment. + + In your commandline environment (current directory `spring-petclinic-microservices`), run: + + ```bash + APP_NAME=chat-service + + AZURE_OPENAI_API_KEY="" + AZURE_OPENAI_ENDPOINT="" + + cp -f ../tools/Dockerfile ./spring-petclinic-$APP_NAME/Dockerfile + az containerapp create \ + --name $APP_NAME \ + --resource-group $RESOURCE_GROUP \ + --environment $ACA_ENVIRONMENT \ + --source ./spring-petclinic-$APP_NAME \ + --registry-server $MYACR.azurecr.io \ + --registry-identity $APPS_IDENTITY_ID \ + --ingress external \ + --target-port 8080 \ + --min-replicas 1 \ + --max-replicas 1 \ + --env-vars AZURE_OPENAI_API_KEY="$AZURE_OPENAI_API_KEY" AZURE_OPENAI_ENDPOINT="$AZURE_OPENAI_ENDPOINT" \ + --bind $JAVA_EUREKA_COMP_NAME \ + --runtime java + ``` + + In your browser navigate to the [Azure portal](http://portal.azure.com) and find the container app `chat-service`. Check the details. + + Verify chat-service in Azure Container Apps. + + ```bash + CHAT_URL=$(az containerapp show \ + --resource-group $RESOURCE_GROUP \ + --name $APP_NAME \ + --query properties.configuration.ingress.fqdn \ + -o tsv) + + curl -XPOST https://$CHAT_URL/chatclient -d 'Hi, tell a joke' + ``` + +1. Function Calling with SpringAI + + In this section, we will implement a basic RAG (Retrieval-Augmented Generation) pattern using Spring AI. The Retrieval-Augmented Generation (RAG) pattern is an industry standard approach to building applications that use large language models to reason over specific or proprietary data that is not already known to the large language model. This is critical because Azure Open AI model that we integrated in the previous step don't know anything about the PetClinic application. Refer to [Spring AI FunctionCallback API](https://docs.spring.io/spring-ai/reference/1.0/api/function-callback.html) for more information. + + {: .note } + The Spring AI API is under development and the interface may change time to time. At this moment, we are using the API version `1.0.0-M4` in this sample. + + In this sample, we will implement a FunctionCallback interface for AI to get the owners information from existing petclinic solution. + + - Create a new `Owner.java` for owner details. + + ```java + package org.springframework.samples.petclinic.chat_service; + + import lombok.Data; + + import java.io.Serializable; + + @Data + public class Owner implements Serializable { + + private Integer id; + + private String firstName; + + private String lastName; + + private String address; + + private String city; + + private String telephone; + } + ``` + + - Create a new file `OwnerService.java` to retrieve owner info from `api-gateway`. + + ```java + package org.springframework.samples.petclinic.chat_service; + + import java.util.List; + + import org.springframework.core.ParameterizedTypeReference; + import org.springframework.http.HttpMethod; + import org.springframework.stereotype.Service; + import org.springframework.web.client.RestTemplate; + + @Service + public class OwnerService { + + public List getOwners() { + + RestTemplate restTemplate = new RestTemplate(); + var responseEntity = restTemplate.exchange( + "http://api-gateway/api/customer/owners", + HttpMethod.GET, + null, + new ParameterizedTypeReference>() { + }); + + List owners = responseEntity.getBody(); + return owners; + } + } + ``` + + - Add FunctionCallbacks to chat client. + + First Add an attribute to ChatController" + + ```java + @Autowired + private OwnerService ownerService; + ``` + + Add Functions to chat client. The new code segment would like, see the `functions` part. + + ```java + ChatResponse response = this.chatClient.prompt() + .system(s -> s.text(systemText)) + .user(userPrompt) + .functions(FunctionCallback.builder() + .description("list all owners") + .method("getOwners") + .targetObject(ownerService) + .build()) + .call() + .chatResponse(); + ``` + + Fix the `import` issues with the help of VSCode. + + - Rebuild the project in the VSCode termimal window (current directory spring-petclinic-chat-service): + + ```bash + mvn clean package -DskipTests + ``` + + - Update the chat-service in your commandline window (current directory spring-petclinic-microservices): + + ```bash + az containerapp update --name $APP_NAME --resource-group $RESOURCE_GROUP \ + --source ./spring-petclinic-$APP_NAME + ``` + + Verify the AI integrated with spring petclinic output: + + ```bash + curl -XPOST https://$CHAT_URL/chatclient -d 'list the names of the owners' + + curl -XPOST https://$CHAT_URL/chatclient -d 'list the owners from Madison' + ``` + + You may create more owners in the api-gateway page and ask more questions on the owners. Note, here we only implemented the owner function. + +1. Here we have finished the first function for AI component to communicate with the existing system. Future more: + + - You may add more functions to empower the AI strenght. Just like the getOwners sample. + - You may integration a chatbox in the api-gateway page. see [Chatbox in api-gateway](#optional-integrate-the-chat-client-into-api-gateway). + +# (Optional) Integrate the chat client into api-gateway + +1. Update service `api-gateway` to add a new chat window. + + - Add new route entry for `chat-service`. Note this name will be used later. Open file `spring-petclinic-api-gateway/src/main/resources/application.yml` and append new entry like below: + + ```yml + - id: chat-service + uri: lb://chat-service + predicates: + - Path=/api/chat/** + filters: + - StripPrefix=2 + ``` + + - Add chatbox for api gateway + + ```bash + git apply -f ../tools/api-gateway-chatbox.patch + ``` + + - Rebuild the api-gateway project and update the container app. + + ```bash + APP_NAME=api-gateway + mvn clean package -DskipTests -pl spring-petclinic-$APP_NAME + az containerapp update --name $APP_NAME --resource-group $RESOURCE_GROUP \ + --source ./spring-petclinic-$APP_NAME + ``` + + - Check the new chatbox in the petclinic page. + + ```bash + api_gateway_FQDN=$(az containerapp show \ + --resource-group $RESOURCE_GROUP \ + --name $APP_NAME \ + --query properties.configuration.ingress.fqdn \ + -o tsv) + + echo https://$api_gateway_FQDN + ``` + + Open the api-gateway url and there is a chatbox at the right bottom corner. + + ![lab 5 api-gateway new chat box](../../images/api-gateway-chatbox.png) + +1. Verify the new RAG empowered AI bot about owners. + + ![lab 5 open-ai-rag-bot](../../images/open-ai-rag-bot.png) + +# (Optional) Prepare the simple project + +1. Create a project from [spring initializr](https://start.spring.io/): + + ```bash + curl https://start.spring.io/starter.tgz \ + -d dependencies=web,cloud-eureka,actuator,lombok,spring-ai-azure-openai \ + -d name=chat-service -d type=maven-project \ + -d jvmVersion=17 -d language=java -d packaging=jar \ + -d groupId=org.springframework.samples.petclinic -d artifactId=chat-service \ + -d description="Spring Petclinic Chat Service" \ + | tar -xzvf - -C spring-petclinic-chat-service + ``` + +1. Update `application.properties` to `application.yml` + + ```yml + spring: + application: + name: chat-service + ``` + +1. Version + + Verify the version of spring-ai is `1.0.0-M4` + + Download the template file from branch `1.0.0.4` + + ```bash + IT_FILE="https://raw.githubusercontent.com/spring-projects/spring-ai/refs/heads/1.0.0-M4/models/spring-ai-azure-openai/src/test/java/org/springframework/ai/azure/openai/AzureOpenAiChatClientIT.java" + wget $IT_FILE -P spring-petclinic-chat-service/src/main/resources/ + ``` + +1. Enable Eureka Client in class `ChatServiceApplication`: + + ![lab 5 eureka client](../../images/open-ai-eureka-client.png) diff --git a/tools/spring-petclinic-chat-service/.gitattributes b/tools/spring-petclinic-chat-service/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/tools/spring-petclinic-chat-service/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/tools/spring-petclinic-chat-service/.gitignore b/tools/spring-petclinic-chat-service/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/tools/spring-petclinic-chat-service/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/tools/spring-petclinic-chat-service/.mvn/wrapper/maven-wrapper.properties b/tools/spring-petclinic-chat-service/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..d58dfb7 --- /dev/null +++ b/tools/spring-petclinic-chat-service/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/tools/spring-petclinic-chat-service/mvnw b/tools/spring-petclinic-chat-service/mvnw new file mode 100755 index 0000000..19529dd --- /dev/null +++ b/tools/spring-petclinic-chat-service/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/tools/spring-petclinic-chat-service/mvnw.cmd b/tools/spring-petclinic-chat-service/mvnw.cmd new file mode 100644 index 0000000..249bdf3 --- /dev/null +++ b/tools/spring-petclinic-chat-service/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/tools/spring-petclinic-chat-service/pom.xml b/tools/spring-petclinic-chat-service/pom.xml new file mode 100644 index 0000000..b9cc2ab --- /dev/null +++ b/tools/spring-petclinic-chat-service/pom.xml @@ -0,0 +1,121 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.1 + + + org.springframework.samples.petclinic + chat-service + 0.0.1-SNAPSHOT + chat-service + Spring Petclinic Chat Service + + + + + + + + + + + + + + + 17 + 1.0.0-M4 + 2024.0.0 + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.ai + spring-ai-azure-openai-spring-boot-starter + + + org.springframework.cloud + spring-cloud-starter-netflix-eureka-client + + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + diff --git a/tools/spring-petclinic-chat-service/src/main/java/org/springframework/samples/petclinic/chat_service/ChatServiceApplication.java b/tools/spring-petclinic-chat-service/src/main/java/org/springframework/samples/petclinic/chat_service/ChatServiceApplication.java new file mode 100644 index 0000000..63ef621 --- /dev/null +++ b/tools/spring-petclinic-chat-service/src/main/java/org/springframework/samples/petclinic/chat_service/ChatServiceApplication.java @@ -0,0 +1,15 @@ +package org.springframework.samples.petclinic.chat_service; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@EnableDiscoveryClient +@SpringBootApplication +public class ChatServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ChatServiceApplication.class, args); + } + +} diff --git a/tools/spring-petclinic-chat-service/src/main/resources/AzureOpenAiChatClientIT.java b/tools/spring-petclinic-chat-service/src/main/resources/AzureOpenAiChatClientIT.java new file mode 100644 index 0000000..e974743 --- /dev/null +++ b/tools/spring-petclinic-chat-service/src/main/resources/AzureOpenAiChatClientIT.java @@ -0,0 +1,176 @@ +/* + * Copyright 2023-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.azure.openai; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import com.azure.ai.openai.OpenAIClientBuilder; +import com.azure.ai.openai.OpenAIServiceVersion; +import com.azure.core.credential.AzureKeyCredential; +import com.azure.core.http.policy.HttpLogOptions; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.Resource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Soby Chacko + */ +@SpringBootTest(classes = AzureOpenAiChatClientIT.TestConfiguration.class) +@RequiresAzureCredentials +public class AzureOpenAiChatClientIT { + + @Autowired + private ChatClient chatClient; + + @Value("classpath:/prompts/system-message.st") + private Resource systemTextResource; + + @Test + void call() { + + // @formatter:off + ChatResponse response = this.chatClient.prompt() + .advisors(new SimpleLoggerAdvisor()) + .system(s -> s.text(this.systemTextResource) + .param("name", "Bob") + .param("voice", "pirate")) + .user("Tell me about 3 famous pirates from the Golden Age of Piracy and what they did") + .call() + .chatResponse(); + // @formatter:on + + assertThat(response.getResults()).hasSize(1); + assertThat(response.getResults().get(0).getOutput().getContent()).contains("Blackbeard"); + } + + @Test + void beanStreamOutputConverterRecords() { + + BeanOutputConverter outputConverter = new BeanOutputConverter<>(ActorsFilms.class); + + // @formatter:off + Flux chatResponse = this.chatClient + .prompt() + .advisors(new SimpleLoggerAdvisor()) + .user(u -> u + .text("Generate the filmography of 5 movies for Tom Hanks. " + System.lineSeparator() + + "{format}") + .param("format", outputConverter.getFormat())) + .stream() + .chatResponse(); + + List chatResponses = chatResponse.collectList() + .block() + .stream() + .toList(); + + String generationTextFromStream = chatResponses + .stream() + .map(cr -> cr.getResult().getOutput().getContent()) + .collect(Collectors.joining()); + // @formatter:on + + ActorsFilms actorsFilms = outputConverter.convert(generationTextFromStream); + + assertThat(actorsFilms.actor()).isEqualTo("Tom Hanks"); + assertThat(actorsFilms.movies()).hasSize(5); + } + + @Test + void streamingAndImperativeResponsesContainIdenticalRelevantResults() { + String prompt = "Name all states in the USA and their capitals, add a space followed by a hyphen, then another space between the two. " + + "List them with a numerical index. Do not use any abbreviations in state or capitals."; + + // Imperative call + String rawDataFromImperativeCall = this.chatClient.prompt(prompt).call().content(); + String imperativeStatesData = extractStatesData(rawDataFromImperativeCall); + String formattedImperativeResponse = formatResponse(imperativeStatesData); + + // Streaming call + String stitchedResponseFromStream = this.chatClient.prompt(prompt) + .stream() + .content() + .collectList() + .block() + .stream() + .collect(Collectors.joining()); + String streamingStatesData = extractStatesData(stitchedResponseFromStream); + String formattedStreamingResponse = formatResponse(streamingStatesData); + + // Assertions + assertThat(formattedStreamingResponse).isEqualTo(formattedImperativeResponse); + assertThat(formattedStreamingResponse).contains("1. Alabama - Montgomery"); + assertThat(formattedStreamingResponse).contains("50. Wyoming - Cheyenne"); + assertThat(formattedStreamingResponse.lines().count()).isEqualTo(50); + } + + private String extractStatesData(String rawData) { + int firstStateIndex = rawData.indexOf("1. Alabama - Montgomery"); + String lastAlphabeticalState = "50. Wyoming - Cheyenne"; + int lastStateIndex = rawData.indexOf(lastAlphabeticalState) + lastAlphabeticalState.length(); + return rawData.substring(firstStateIndex, lastStateIndex); + } + + private String formatResponse(String response) { + return String.join("\n", Arrays.stream(response.split("\n")).map(String::strip).toArray(String[]::new)); + } + + record ActorsFilms(String actor, List movies) { + + } + + @SpringBootConfiguration + public static class TestConfiguration { + + @Bean + public OpenAIClientBuilder openAIClient() { + return new OpenAIClientBuilder().credential(new AzureKeyCredential(System.getenv("AZURE_OPENAI_API_KEY"))) + .endpoint(System.getenv("AZURE_OPENAI_ENDPOINT")) + .serviceVersion(OpenAIServiceVersion.V2024_02_15_PREVIEW) + .httpLogOptions(new HttpLogOptions() + .setLogLevel(com.azure.core.http.policy.HttpLogDetailLevel.BODY_AND_HEADERS)); + } + + @Bean + public AzureOpenAiChatModel azureOpenAiChatModel(OpenAIClientBuilder openAIClientBuilder) { + return new AzureOpenAiChatModel(openAIClientBuilder, + AzureOpenAiChatOptions.builder().withDeploymentName("gpt-4o").withMaxTokens(1000).build()); + + } + + @Bean + public ChatClient chatClient(AzureOpenAiChatModel azureOpenAiChatModel) { + return ChatClient.builder(azureOpenAiChatModel).build(); + } + + } + +} diff --git a/tools/spring-petclinic-chat-service/src/main/resources/application.yml b/tools/spring-petclinic-chat-service/src/main/resources/application.yml new file mode 100644 index 0000000..3f9eee8 --- /dev/null +++ b/tools/spring-petclinic-chat-service/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + application: + name: chat-service