diff --git a/03-output-converters/output-converters-openai/src/main/java/com/thomasvitale/ai/spring/ChatService.java b/03-output-converters/output-converters-openai/src/main/java/com/thomasvitale/ai/spring/ChatService.java index 61376f7..f96ddcf 100644 --- a/03-output-converters/output-converters-openai/src/main/java/com/thomasvitale/ai/spring/ChatService.java +++ b/03-output-converters/output-converters-openai/src/main/java/com/thomasvitale/ai/spring/ChatService.java @@ -33,7 +33,7 @@ ArtistInfo chatWithBeanOutput(MusicQuestion question) { .param("instrument", question.instrument()) ) .options(OpenAiChatOptions.builder() - .withResponseFormat(new OpenAiApi.ChatCompletionRequest.ResponseFormat("json_object")) + .withResponseFormat(new OpenAiApi.ChatCompletionRequest.ResponseFormat(OpenAiApi.ChatCompletionRequest.ResponseFormat.Type.JSON_OBJECT)) .build()) .call() .entity(ArtistInfo.class); diff --git a/04-embedding-models/embedding-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java b/04-embedding-models/embedding-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java index 09a12fa..d196daf 100644 --- a/04-embedding-models/embedding-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java +++ b/04-embedding-models/embedding-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java @@ -21,7 +21,7 @@ class EmbeddingController { @GetMapping("/embed") String embed(@RequestParam(defaultValue = "And Gandalf yelled: 'You shall not pass!'") String message) { var embeddings = embeddingModel.embed(message); - return "Size of the embedding vector: " + embeddings.size(); + return "Size of the embedding vector: " + embeddings.length; } @GetMapping("/embed/mistral-ai-options") @@ -30,7 +30,7 @@ String embedWithMistralAiOptions(@RequestParam(defaultValue = "And Gandalf yelle .withModel("mistral-embed") .build())) .getResult().getOutput(); - return "Size of the embedding vector: " + embeddings.size(); + return "Size of the embedding vector: " + embeddings.length; } } diff --git a/04-embedding-models/embedding-models-ollama/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java b/04-embedding-models/embedding-models-ollama/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java index ab3f0f8..f91a21d 100644 --- a/04-embedding-models/embedding-models-ollama/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java +++ b/04-embedding-models/embedding-models-ollama/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java @@ -21,7 +21,7 @@ class EmbeddingController { @GetMapping("/embed") String embed(@RequestParam(defaultValue = "And Gandalf yelled: 'You shall not pass!'") String message) { var embeddings = embeddingModel.embed(message); - return "Size of the embedding vector: " + embeddings.size(); + return "Size of the embedding vector: " + embeddings.length; } @GetMapping("/embed/ollama-options") @@ -29,7 +29,7 @@ String embedWithOllamaOptions(@RequestParam(defaultValue = "And Gandalf yelled: var embeddings = embeddingModel.call(new EmbeddingRequest(List.of(message), OllamaOptions.create() .withModel("mistral"))) .getResult().getOutput(); - return "Size of the embedding vector: " + embeddings.size(); + return "Size of the embedding vector: " + embeddings.length; } } diff --git a/04-embedding-models/embedding-models-openai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java b/04-embedding-models/embedding-models-openai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java index 227497b..6c295af 100644 --- a/04-embedding-models/embedding-models-openai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java +++ b/04-embedding-models/embedding-models-openai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java @@ -21,7 +21,7 @@ class EmbeddingController { @GetMapping("/embed") String embed(@RequestParam(defaultValue = "And Gandalf yelled: 'You shall not pass!'") String message) { var embeddings = embeddingModel.embed(message); - return "Size of the embedding vector: " + embeddings.size(); + return "Size of the embedding vector: " + embeddings.length; } @GetMapping("/embed/openai-options") @@ -31,7 +31,7 @@ String embedWithOpenAiOptions(@RequestParam(defaultValue = "And Gandalf yelled: .withUser("jon.snow") .build())) .getResult().getOutput(); - return "Size of the embedding vector: " + embeddings.size(); + return "Size of the embedding vector: " + embeddings.length; } } diff --git a/04-embedding-models/embedding-models-transformers/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java b/04-embedding-models/embedding-models-transformers/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java index c653297..3d67768 100644 --- a/04-embedding-models/embedding-models-transformers/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java +++ b/04-embedding-models/embedding-models-transformers/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java @@ -17,7 +17,7 @@ class EmbeddingController { @GetMapping("/embed") String embed(@RequestParam(defaultValue = "And Gandalf yelled: 'You shall not pass!'") String message) { var embeddings = embeddingModel.embed(message); - return "Size of the embedding vector: " + embeddings.size(); + return "Size of the embedding vector: " + embeddings.length; } } diff --git a/10-observability/observability-models-mistral-ai/README.md b/10-observability/observability-models-mistral-ai/README.md new file mode 100644 index 0000000..39d24dc --- /dev/null +++ b/10-observability/observability-models-mistral-ai/README.md @@ -0,0 +1,84 @@ +# LLM Observability: Mistral AI + +LLM Observability for Mistral AI. + +## Running the application + +The application relies on a Mistral AI API for providing LLMs. The application also relies on Testcontainers +to provision automatically a Grafana LGTM observability stack. + +### When using OpenAI + +First, make sure you have a [Mistral AI account](https://console.mistral.ai). +Then, define an environment variable with the Mistral AI API Key associated to your Mistral AI account as the value. + +```shell +export SPRING_AI_MISTRALAI_API_KEY= +``` + +Finally, run the Spring Boot application. + +```shell +./gradlew bootTestRun +``` + +## Observability Platform + +Grafana is listening to port 3000. Check your container runtime to find the port to which is exposed to your localhost +and access Grafana from http://localhost:. The credentials are `admin`/`admin`. + +The application is automatically configured to export metrics and traces to the Grafana LGTM stack via OpenTelemetry. +In Grafana, you can query the traces from the "Explore" page, selecting the "Tempo" data source. You can also visualize metrics in "Explore > Metrics". + +## Calling the application + +You can now call the application to perform generative AI operations. +This example uses [httpie](https://httpie.io) to send HTTP requests. + +### Chat + +```shell +http :8080/chat +``` + +Try passing your custom prompt and check the result. + +```shell +http :8080/chat message=="What is the capital of Italy?" +``` + +The next request is configured with a custom temperature value to obtain a more creative, yet less precise answer. + +```shell +http :8080/chat/generic-options message=="Why is a raven like a writing desk? Give a short answer." +``` + +The next request is configured with Open AI-specific customizations. + +```shell +http :8080/chat/mistral-ai-options message=="What can you see beyond what you can see? Give a short answer." +``` + +Finally, try a request which uses function calling. + +```shell +http :8080/chat/functions authorName=="Philip Pullman" +``` + +### Embedding + +```shell +http :8080/embed +``` + +Try passing your custom prompt and check the result. + +```shell +http :8080/embed message=="The capital of Italy is Rome" +``` + +The next request is configured with OpenAI-specific customizations. + +```shell +http :8080/embed/mistral-ai-options message=="The capital of Italy is Rome" +``` diff --git a/10-observability/observability-models-mistral-ai/build.gradle b/10-observability/observability-models-mistral-ai/build.gradle new file mode 100644 index 0000000..f861093 --- /dev/null +++ b/10-observability/observability-models-mistral-ai/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'java' + id 'org.springframework.boot' + id 'io.spring.dependency-management' +} + +group = 'com.thomasvitale' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(22) + } +} + +repositories { + mavenCentral() + maven { url 'https://repo.spring.io/milestone' } + maven { url 'https://repo.spring.io/snapshot' } +} + +dependencies { + implementation platform("org.springframework.ai:spring-ai-bom:${springAiVersion}") + + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.ai:spring-ai-mistral-ai-spring-boot-starter' + + implementation 'io.micrometer:micrometer-tracing-bridge-otel' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp' + implementation 'io.micrometer:micrometer-registry-otlp' + implementation 'net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.5' + + testAndDevelopmentOnly 'org.springframework.boot:spring-boot-devtools' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/BookService.java b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/BookService.java new file mode 100644 index 0000000..4ea663e --- /dev/null +++ b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/BookService.java @@ -0,0 +1,40 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class BookService { + + private static final Map books = new ConcurrentHashMap<>(); + + static { + books.put(1, new Book("His Dark Materials", "Philip Pullman")); + books.put(2, new Book("Narnia", "C.S. Lewis")); + books.put(3, new Book("The Hobbit", "J.R.R. Tolkien")); + books.put(4, new Book("The Lord of The Rings", "J.R.R. Tolkien")); + books.put(5, new Book("The Silmarillion", "J.R.R. Tolkien")); + } + + List getBooksByAuthor(Author author) { + return books.values().stream() + .filter(book -> author.name().equals(book.author())) + .toList(); + } + + Book getBestsellerByAuthor(Author author) { + return switch (author.name()) { + case "J.R.R. Tolkien" -> books.get(4); + case "C.S. Lewis" -> books.get(2); + case "Philip Pullman" -> books.get(1); + default -> null; + }; + } + + public record Book(String title, String author) {} + public record Author(String name) {} + +} diff --git a/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/ChatController.java b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/ChatController.java new file mode 100644 index 0000000..f30ebc8 --- /dev/null +++ b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/ChatController.java @@ -0,0 +1,62 @@ +package com.thomasvitale.ai.spring; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.prompt.ChatOptionsBuilder; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.mistralai.MistralAiChatOptions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Set; + +@RestController +class ChatController { + + private final Logger logger = LoggerFactory.getLogger(ChatController.class); + + private final ChatModel chatModel; + + ChatController(ChatModel chatModel) { + this.chatModel = chatModel; + } + + @GetMapping("/chat") + String chat(@RequestParam(defaultValue = "What did Gandalf say to the Balrog?") String message) { + logger.info(message); + return chatModel.call(message); + } + + @GetMapping("/chat/generic-options") + String chatWithGenericOptions(@RequestParam(defaultValue = "What did Gandalf say to the Balrog?") String message) { + return chatModel.call(new Prompt(message, ChatOptionsBuilder.builder() + .withTemperature(1.3f) + .build())) + .getResult().getOutput().getContent(); + } + + @GetMapping("/chat/mistral-ai-options") + String chatWithMistralAiOptions(@RequestParam(defaultValue = "What did Gandalf say to the Balrog?") String message) { + return chatModel.call(new Prompt(message, MistralAiChatOptions.builder() + .withMaxTokens(1500) + .withStop(List.of("this-is-the-end", "addio")) + .withTemperature(0.7f) + .withTopP(0.1f) + .build())) + .getResult().getOutput().getContent(); + } + + @GetMapping("/chat/functions") + String chatWithFunctions(@RequestParam(defaultValue = "Philip Pullman") String author) { + return chatModel.call(new Prompt("What books written by %s are available to read and what is their bestseller?".formatted(author), + MistralAiChatOptions.builder() + .withTemperature(0.3f) + .withFunctions(Set.of("booksByAuthor", "bestsellerBookByAuthor")) + .build())) + .getResult().getOutput().getContent(); + } + +} diff --git a/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java new file mode 100644 index 0000000..b375a76 --- /dev/null +++ b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java @@ -0,0 +1,36 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.mistralai.MistralAiEmbeddingOptions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +class EmbeddingController { + + private final EmbeddingModel embeddingModel; + + EmbeddingController(EmbeddingModel embeddingModel) { + this.embeddingModel = embeddingModel; + } + + @GetMapping("/embed") + String embed(@RequestParam(defaultValue = "And Gandalf yelled: 'You shall not pass!'") String message) { + var embeddings = embeddingModel.embed(message); + return "Size of the embedding vector: " + embeddings.length; + } + + @GetMapping("/embed/mistral-ai-options") + String embedWithMistralAiOptions(@RequestParam(defaultValue = "And Gandalf yelled: 'You shall not pass!'") String message) { + var embeddings = embeddingModel.call(new EmbeddingRequest(List.of(message), MistralAiEmbeddingOptions.builder() + .withEncodingFormat("float") + .build())) + .getResult().getOutput(); + return "Size of the embedding vector: " + embeddings.length; + } + +} diff --git a/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/Functions.java b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/Functions.java new file mode 100644 index 0000000..da3d930 --- /dev/null +++ b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/Functions.java @@ -0,0 +1,25 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import java.util.List; +import java.util.function.Function; + +@Configuration(proxyBeanMethods = false) +public class Functions { + + @Bean + @Description("Get the list of available books written by the given author") + public Function> booksByAuthor(BookService bookService) { + return bookService::getBooksByAuthor; + } + + @Bean + @Description("Get the bestseller book written by the given author") + public Function bestsellerBookByAuthor(BookService bookService) { + return bookService::getBestsellerByAuthor; + } + +} diff --git a/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/HttpClientConfig.java b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/HttpClientConfig.java new file mode 100644 index 0000000..71e3f35 --- /dev/null +++ b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/HttpClientConfig.java @@ -0,0 +1,27 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.BufferingClientHttpRequestFactory; + +import java.time.Duration; + +@Configuration(proxyBeanMethods = false) +public class HttpClientConfig { + + @Bean + RestClientCustomizer restClientCustomizer() { + return restClientBuilder -> { + restClientBuilder + .requestFactory(new BufferingClientHttpRequestFactory( + ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS + .withConnectTimeout(Duration.ofSeconds(60)) + .withReadTimeout(Duration.ofSeconds(60)) + ))); + }; + } + +} diff --git a/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/ObservabilityModelsMistralAiApplication.java b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/ObservabilityModelsMistralAiApplication.java new file mode 100644 index 0000000..fdcfdbb --- /dev/null +++ b/10-observability/observability-models-mistral-ai/src/main/java/com/thomasvitale/ai/spring/ObservabilityModelsMistralAiApplication.java @@ -0,0 +1,13 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ObservabilityModelsMistralAiApplication { + + public static void main(String[] args) { + SpringApplication.run(ObservabilityModelsMistralAiApplication.class, args); + } + +} diff --git a/10-observability/observability-models-mistral-ai/src/main/resources/application.yml b/10-observability/observability-models-mistral-ai/src/main/resources/application.yml new file mode 100644 index 0000000..35a0bff --- /dev/null +++ b/10-observability/observability-models-mistral-ai/src/main/resources/application.yml @@ -0,0 +1,35 @@ +spring: + application: + name: observability-models-mistral-ai + ai: + chat: + observations: + include-completion: true + include-prompt: true + image: + observations: + include-prompt: true + mistralai: + api-key: ${MISTRALAI_API_KEY} + chat: + options: + model: mistral-small-latest + temperature: 0.7 + embedding: + options: + model: mistral-embed + +management: + endpoints: + web: + exposure: + include: "*" + metrics: + tags: + service.name: ${spring.application.name} + tracing: + sampling: + probability: 1.0 + otlp: + tracing: + endpoint: http://localhost:4318/v1/traces diff --git a/10-observability/observability-models-mistral-ai/src/test/java/com/thomasvitale/ai/spring/ObservabilityModelsMistralAiApplicationTests.java b/10-observability/observability-models-mistral-ai/src/test/java/com/thomasvitale/ai/spring/ObservabilityModelsMistralAiApplicationTests.java new file mode 100644 index 0000000..3331c5f --- /dev/null +++ b/10-observability/observability-models-mistral-ai/src/test/java/com/thomasvitale/ai/spring/ObservabilityModelsMistralAiApplicationTests.java @@ -0,0 +1,13 @@ +package com.thomasvitale.ai.spring; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ObservabilityModelsMistralAiApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/10-observability/observability-models-mistral-ai/src/test/java/com/thomasvitale/ai/spring/TestObservabilityModelsMistralAiApplication.java b/10-observability/observability-models-mistral-ai/src/test/java/com/thomasvitale/ai/spring/TestObservabilityModelsMistralAiApplication.java new file mode 100644 index 0000000..cee08d6 --- /dev/null +++ b/10-observability/observability-models-mistral-ai/src/test/java/com/thomasvitale/ai/spring/TestObservabilityModelsMistralAiApplication.java @@ -0,0 +1,11 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.SpringApplication; + +public class TestObservabilityModelsMistralAiApplication { + + public static void main(String[] args) { + SpringApplication.from(ObservabilityModelsMistralAiApplication::main).with(TestcontainersConfiguration.class).run(args); + } + +} diff --git a/10-observability/observability-models-mistral-ai/src/test/java/com/thomasvitale/ai/spring/TestcontainersConfiguration.java b/10-observability/observability-models-mistral-ai/src/test/java/com/thomasvitale/ai/spring/TestcontainersConfiguration.java new file mode 100644 index 0000000..66e5c8f --- /dev/null +++ b/10-observability/observability-models-mistral-ai/src/test/java/com/thomasvitale/ai/spring/TestcontainersConfiguration.java @@ -0,0 +1,29 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.devtools.restart.RestartScope; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Scope; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.time.Duration; + +@TestConfiguration(proxyBeanMethods = false) +class TestcontainersConfiguration { + + @Bean + @RestartScope + @Scope("singleton") + @ServiceConnection("otel/opentelemetry-collector-contrib") + GenericContainer lgtmContainer() { + return new GenericContainer<>("docker.io/grafana/otel-lgtm:0.7.1") + .withExposedPorts(3000, 4317, 4318) + .withEnv("OTEL_METRIC_EXPORT_INTERVAL", "500") + .waitingFor(Wait.forLogMessage(".*The OpenTelemetry collector and the Grafana LGTM stack are up and running.*\\s", 1)) + .withStartupTimeout(Duration.ofMinutes(2)) + .withReuse(true); + } + +} diff --git a/10-observability/observability-models-ollama/README.md b/10-observability/observability-models-ollama/README.md new file mode 100644 index 0000000..beead97 --- /dev/null +++ b/10-observability/observability-models-ollama/README.md @@ -0,0 +1,85 @@ +# LLM Observability: Ollama + +LLM Observability for Ollama. + +## Running the application + +The application relies on Ollama for providing LLMs. The application also relies on Testcontainers to provision automatically +a Grafana LGTM observability stack. + +### Ollama as a native application + +First, make sure you have [Ollama](https://ollama.ai) installed on your laptop. +Then, use Ollama to run the _mistral_ and _nomic-embed-text_ models. Those are the ones we'll use in this example. + +```shell +ollama run mistral +ollama run nomic-embed-text +``` + +Finally, run the Spring Boot application. + +```shell +./gradlew bootTestRun +``` + +## Observability Platform + +Grafana is listening to port 3000. Check your container runtime to find the port to which is exposed to your localhost +and access Grafana from http://localhost:. The credentials are `admin`/`admin`. + +The application is automatically configured to export metrics and traces to the Grafana LGTM stack via OpenTelemetry. +In Grafana, you can query the traces from the "Explore" page, selecting the "Tempo" data source. You can also visualize metrics in "Explore > Metrics". + +## Calling the application + +You can now call the application to perform generative AI operations. +This example uses [httpie](https://httpie.io) to send HTTP requests. + +### Chat + +```shell +http :8080/chat +``` + +Try passing your custom prompt and check the result. + +```shell +http :8080/chat message=="What is the capital of Italy?" +``` + +The next request is configured with a custom temperature value to obtain a more creative, yet less precise answer. + +```shell +http :8080/chat/generic-options message=="Why is a raven like a writing desk? Give a short answer." +``` + +The next request is configured with Ollama-specific customizations. + +```shell +http :8080/chat/ollama-options message=="What can you see beyond what you can see? Give a short answer." +``` + +Finally, try a request which uses function calling. + +```shell +http :8080/chat/functions authorName=="Philip Pullman" +``` + +### Embedding + +```shell +http :8080/embed +``` + +Try passing your custom prompt and check the result. + +```shell +http :8080/embed message=="The capital of Italy is Rome" +``` + +The next request is configured with Ollama-specific customizations. + +```shell +http :8080/embed/ollama-options message=="The capital of Italy is Rome" +``` diff --git a/10-observability/observability-models-ollama/build.gradle b/10-observability/observability-models-ollama/build.gradle new file mode 100644 index 0000000..29fc333 --- /dev/null +++ b/10-observability/observability-models-ollama/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'java' + id 'org.springframework.boot' + id 'io.spring.dependency-management' +} + +group = 'com.thomasvitale' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(22) + } +} + +repositories { + mavenCentral() + maven { url 'https://repo.spring.io/milestone' } + maven { url 'https://repo.spring.io/snapshot' } +} + +dependencies { + implementation platform("org.springframework.ai:spring-ai-bom:${springAiVersion}") + + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.ai:spring-ai-ollama-spring-boot-starter' + + implementation 'io.micrometer:micrometer-tracing-bridge-otel' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp' + implementation 'io.micrometer:micrometer-registry-otlp' + implementation 'net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.5' + + testAndDevelopmentOnly 'org.springframework.boot:spring-boot-devtools' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/BookService.java b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/BookService.java new file mode 100644 index 0000000..4ea663e --- /dev/null +++ b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/BookService.java @@ -0,0 +1,40 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class BookService { + + private static final Map books = new ConcurrentHashMap<>(); + + static { + books.put(1, new Book("His Dark Materials", "Philip Pullman")); + books.put(2, new Book("Narnia", "C.S. Lewis")); + books.put(3, new Book("The Hobbit", "J.R.R. Tolkien")); + books.put(4, new Book("The Lord of The Rings", "J.R.R. Tolkien")); + books.put(5, new Book("The Silmarillion", "J.R.R. Tolkien")); + } + + List getBooksByAuthor(Author author) { + return books.values().stream() + .filter(book -> author.name().equals(book.author())) + .toList(); + } + + Book getBestsellerByAuthor(Author author) { + return switch (author.name()) { + case "J.R.R. Tolkien" -> books.get(4); + case "C.S. Lewis" -> books.get(2); + case "Philip Pullman" -> books.get(1); + default -> null; + }; + } + + public record Book(String title, String author) {} + public record Author(String name) {} + +} diff --git a/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/ChatController.java b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/ChatController.java new file mode 100644 index 0000000..f62c513 --- /dev/null +++ b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/ChatController.java @@ -0,0 +1,65 @@ +package com.thomasvitale.ai.spring; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.prompt.ChatOptionsBuilder; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Set; + +@RestController +class ChatController { + + private final Logger logger = LoggerFactory.getLogger(ChatController.class); + + private final ChatModel chatModel; + + ChatController(ChatModel chatModel) { + this.chatModel = chatModel; + } + + @GetMapping("/chat") + String chat(@RequestParam(defaultValue = "What did Gandalf say to the Balrog?") String message) { + logger.info(message); + return chatModel.call(message); + } + + @GetMapping("/chat/generic-options") + String chatWithGenericOptions(@RequestParam(defaultValue = "What did Gandalf say to the Balrog?") String message) { + return chatModel.call(new Prompt(message, ChatOptionsBuilder.builder() + .withTemperature(1.3f) + .build())) + .getResult().getOutput().getContent(); + } + + @GetMapping("/chat/ollama-options") + String chatWithOllamaOptions(@RequestParam(defaultValue = "What did Gandalf say to the Balrog?") String message) { + return chatModel.call(new Prompt(message, OllamaOptions.builder() + .withFrequencyPenalty(1.3f) + .withNumPredict(1500) + .withPresencePenalty(1.0f) + .withStop(List.of("this-is-the-end", "addio")) + .withTemperature(0.7f) + .withTopK(1) + .withTopP(0f) + .build())) + .getResult().getOutput().getContent(); + } + + @GetMapping("/chat/functions") + String chatWithFunctions(@RequestParam(defaultValue = "Philip Pullman") String author) { + return chatModel.call(new Prompt("What books written by %s are available to read and what is their bestseller?".formatted(author), + OllamaOptions.builder() + .withTemperature(0.3f) + .withFunctions(Set.of("booksByAuthor", "bestsellerBookByAuthor")) + .build())) + .getResult().getOutput().getContent(); + } + +} diff --git a/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java new file mode 100644 index 0000000..a4e0313 --- /dev/null +++ b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java @@ -0,0 +1,35 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.ollama.api.OllamaOptions; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +class EmbeddingController { + + private final EmbeddingModel embeddingModel; + + EmbeddingController(EmbeddingModel embeddingModel) { + this.embeddingModel = embeddingModel; + } + + @GetMapping("/embed") + String embed(@RequestParam(defaultValue = "And Gandalf yelled: 'You shall not pass!'") String message) { + var embeddings = embeddingModel.embed(message); + return "Size of the embedding vector: " + embeddings.length; + } + + @GetMapping("/embed/ollama-options") + String embedWithOllamaOptions(@RequestParam(defaultValue = "And Gandalf yelled: 'You shall not pass!'") String message) { + var embeddings = embeddingModel.call(new EmbeddingRequest(List.of(message), OllamaOptions.builder() + .build())) + .getResult().getOutput(); + return "Size of the embedding vector: " + embeddings.length; + } + +} diff --git a/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/Functions.java b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/Functions.java new file mode 100644 index 0000000..da3d930 --- /dev/null +++ b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/Functions.java @@ -0,0 +1,25 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Description; + +import java.util.List; +import java.util.function.Function; + +@Configuration(proxyBeanMethods = false) +public class Functions { + + @Bean + @Description("Get the list of available books written by the given author") + public Function> booksByAuthor(BookService bookService) { + return bookService::getBooksByAuthor; + } + + @Bean + @Description("Get the bestseller book written by the given author") + public Function bestsellerBookByAuthor(BookService bookService) { + return bookService::getBestsellerByAuthor; + } + +} diff --git a/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/HttpClientConfig.java b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/HttpClientConfig.java new file mode 100644 index 0000000..71e3f35 --- /dev/null +++ b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/HttpClientConfig.java @@ -0,0 +1,27 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.BufferingClientHttpRequestFactory; + +import java.time.Duration; + +@Configuration(proxyBeanMethods = false) +public class HttpClientConfig { + + @Bean + RestClientCustomizer restClientCustomizer() { + return restClientBuilder -> { + restClientBuilder + .requestFactory(new BufferingClientHttpRequestFactory( + ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS + .withConnectTimeout(Duration.ofSeconds(60)) + .withReadTimeout(Duration.ofSeconds(60)) + ))); + }; + } + +} diff --git a/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/ObservabilityModelsOllamaApplication.java b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/ObservabilityModelsOllamaApplication.java new file mode 100644 index 0000000..086023c --- /dev/null +++ b/10-observability/observability-models-ollama/src/main/java/com/thomasvitale/ai/spring/ObservabilityModelsOllamaApplication.java @@ -0,0 +1,13 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ObservabilityModelsOllamaApplication { + + public static void main(String[] args) { + SpringApplication.run(ObservabilityModelsOllamaApplication.class, args); + } + +} diff --git a/10-observability/observability-models-ollama/src/main/resources/application.yml b/10-observability/observability-models-ollama/src/main/resources/application.yml new file mode 100644 index 0000000..1148f49 --- /dev/null +++ b/10-observability/observability-models-ollama/src/main/resources/application.yml @@ -0,0 +1,34 @@ +spring: + application: + name: observability-models-ollama + ai: + chat: + observations: + include-completion: true + include-prompt: true + image: + observations: + include-prompt: true + ollama: + chat: + options: + model: mistral + temperature: 0.7 + embedding: + options: + model: nomic-embed-text + +management: + endpoints: + web: + exposure: + include: "*" + metrics: + tags: + service.name: ${spring.application.name} + tracing: + sampling: + probability: 1.0 + otlp: + tracing: + endpoint: http://localhost:4318/v1/traces diff --git a/10-observability/observability-models-ollama/src/test/java/com/thomasvitale/ai/spring/ObservabilityModelsOllamaApplicationTests.java b/10-observability/observability-models-ollama/src/test/java/com/thomasvitale/ai/spring/ObservabilityModelsOllamaApplicationTests.java new file mode 100644 index 0000000..1bc4392 --- /dev/null +++ b/10-observability/observability-models-ollama/src/test/java/com/thomasvitale/ai/spring/ObservabilityModelsOllamaApplicationTests.java @@ -0,0 +1,13 @@ +package com.thomasvitale.ai.spring; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ObservabilityModelsOllamaApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/10-observability/observability-models-ollama/src/test/java/com/thomasvitale/ai/spring/TestObservabilityModelsOllamaApplication.java b/10-observability/observability-models-ollama/src/test/java/com/thomasvitale/ai/spring/TestObservabilityModelsOllamaApplication.java new file mode 100644 index 0000000..2be8fda --- /dev/null +++ b/10-observability/observability-models-ollama/src/test/java/com/thomasvitale/ai/spring/TestObservabilityModelsOllamaApplication.java @@ -0,0 +1,11 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.SpringApplication; + +public class TestObservabilityModelsOllamaApplication { + + public static void main(String[] args) { + SpringApplication.from(ObservabilityModelsOllamaApplication::main).with(TestcontainersConfiguration.class).run(args); + } + +} diff --git a/10-observability/observability-models-ollama/src/test/java/com/thomasvitale/ai/spring/TestcontainersConfiguration.java b/10-observability/observability-models-ollama/src/test/java/com/thomasvitale/ai/spring/TestcontainersConfiguration.java new file mode 100644 index 0000000..66e5c8f --- /dev/null +++ b/10-observability/observability-models-ollama/src/test/java/com/thomasvitale/ai/spring/TestcontainersConfiguration.java @@ -0,0 +1,29 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.devtools.restart.RestartScope; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Scope; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; + +import java.time.Duration; + +@TestConfiguration(proxyBeanMethods = false) +class TestcontainersConfiguration { + + @Bean + @RestartScope + @Scope("singleton") + @ServiceConnection("otel/opentelemetry-collector-contrib") + GenericContainer lgtmContainer() { + return new GenericContainer<>("docker.io/grafana/otel-lgtm:0.7.1") + .withExposedPorts(3000, 4317, 4318) + .withEnv("OTEL_METRIC_EXPORT_INTERVAL", "500") + .waitingFor(Wait.forLogMessage(".*The OpenTelemetry collector and the Grafana LGTM stack are up and running.*\\s", 1)) + .withStartupTimeout(Duration.ofMinutes(2)) + .withReuse(true); + } + +} diff --git a/10-observability/observability-models-openai/build.gradle b/10-observability/observability-models-openai/build.gradle index e620b73..8112db5 100644 --- a/10-observability/observability-models-openai/build.gradle +++ b/10-observability/observability-models-openai/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'io.micrometer:micrometer-tracing-bridge-otel' implementation 'io.opentelemetry:opentelemetry-exporter-otlp' implementation 'io.micrometer:micrometer-registry-otlp' + implementation 'net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.5' testAndDevelopmentOnly 'org.springframework.boot:spring-boot-devtools' diff --git a/10-observability/observability-models-openai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java b/10-observability/observability-models-openai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java index 5094ebf..7986f7f 100644 --- a/10-observability/observability-models-openai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java +++ b/10-observability/observability-models-openai/src/main/java/com/thomasvitale/ai/spring/EmbeddingController.java @@ -21,7 +21,7 @@ class EmbeddingController { @GetMapping("/embed") String embed(@RequestParam(defaultValue = "And Gandalf yelled: 'You shall not pass!'") String message) { var embeddings = embeddingModel.embed(message); - return "Size of the embedding vector: " + embeddings.size(); + return "Size of the embedding vector: " + embeddings.length; } @GetMapping("/embed/openai-options") @@ -31,7 +31,7 @@ String embedWithOpenAiOptions(@RequestParam(defaultValue = "And Gandalf yelled: .withEncodingFormat("float") .build())) .getResult().getOutput(); - return "Size of the embedding vector: " + embeddings.size(); + return "Size of the embedding vector: " + embeddings.length; } } diff --git a/10-observability/observability-vector-stores-pgvector/README.md b/10-observability/observability-vector-stores-pgvector/README.md new file mode 100644 index 0000000..08938f8 --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/README.md @@ -0,0 +1,61 @@ +# Vector Store Observability: PGVector + +Vector Store Observability for PGVector. + +## Running the application + +The application relies on an OpenAI API for providing LLMs. This example guides you to use either the OpenAI Platform +or Ollama via the OpenAI-compatible API. The application also relies on Testcontainers to provision automatically +a Grafana LGTM observability stack and a PGVector database. + +### When using OpenAI + +First, make sure you have an OpenAI account. +Then, define an environment variable with the OpenAI API Key associated to your OpenAI account as the value. + +```shell +export SPRING_AI_OPENAI_API_KEY= +``` + +Finally, run the Spring Boot application. + +```shell +./gradlew bootTestRun +``` + +### When using Ollama as a native application + +First, make sure you have [Ollama](https://ollama.ai) installed on your laptop. +Then, use Ollama to run the _mistral_ and _nomic-embed-text_ models. Those are the ones we'll use in this example. + +```shell +ollama run mistral +ollama run nomic-embed-text +``` + +Finally, run the Spring Boot application. + +```shell +./gradlew bootTestRun --args='--spring.profiles.active=ollama' +``` + +## Observability Platform + +Grafana is listening to port 3000. Check your container runtime to find the port to which is exposed to your localhost +and access Grafana from http://localhost:. The credentials are `admin`/`admin`. + +The application is automatically configured to export metrics and traces to the Grafana LGTM stack via OpenTelemetry. +In Grafana, you can query the traces from the "Explore" page, selecting the "Tempo" data source. You can also visualize metrics in "Explore > Metrics". + +## Calling the application + +You can now call the application to perform generative AI operations. +This example uses [httpie](https://httpie.io) to send HTTP requests. + +```shell +http --raw "What is Iorek's biggest dream?" :8080/chat/doc +``` + +```shell +http --raw "Who is Lucio?" :8080/chat/doc +``` diff --git a/10-observability/observability-vector-stores-pgvector/build.gradle b/10-observability/observability-vector-stores-pgvector/build.gradle new file mode 100644 index 0000000..681e84b --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/build.gradle @@ -0,0 +1,46 @@ +plugins { + id 'java' + id 'org.springframework.boot' + id 'io.spring.dependency-management' +} + +group = 'com.thomasvitale' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(22) + } +} + +repositories { + mavenCentral() + maven { url 'https://repo.spring.io/milestone' } + maven { url 'https://repo.spring.io/snapshot' } +} + +dependencies { + implementation platform("org.springframework.ai:spring-ai-bom:${springAiVersion}") + + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-pgvector-store-spring-boot-starter' + + implementation 'io.micrometer:micrometer-tracing-bridge-otel' + implementation 'io.opentelemetry:opentelemetry-exporter-otlp' + implementation 'io.micrometer:micrometer-registry-otlp' + implementation 'net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.5' + + testAndDevelopmentOnly 'org.springframework.boot:spring-boot-devtools' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webflux' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.springframework.ai:spring-ai-spring-boot-testcontainers' + testImplementation 'org.testcontainers:postgresql' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/ChatController.java b/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/ChatController.java new file mode 100644 index 0000000..16aafd3 --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/ChatController.java @@ -0,0 +1,21 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class ChatController { + + private final ChatService chatService; + + ChatController(ChatService chatService) { + this.chatService = chatService; + } + + @PostMapping("/chat/doc") + String chatWithDocument(@RequestBody String input) { + return chatService.chatWithDocument(input); + } + +} diff --git a/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/ChatService.java b/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/ChatService.java new file mode 100644 index 0000000..c128188 --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/ChatService.java @@ -0,0 +1,27 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.stereotype.Service; + +@Service +class ChatService { + + private final ChatClient chatClient; + private final VectorStore vectorStore; + + ChatService(ChatClient.Builder chatClientBuilder, VectorStore vectorStore) { + this.chatClient = chatClientBuilder.build(); + this.vectorStore = vectorStore; + } + + String chatWithDocument(String message) { + return chatClient.prompt() + .advisors(new QuestionAnswerAdvisor(vectorStore)) + .user(message) + .call() + .content(); + } + +} diff --git a/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/IngestionPipeline.java b/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/IngestionPipeline.java new file mode 100644 index 0000000..a0c7c05 --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/IngestionPipeline.java @@ -0,0 +1,54 @@ +package com.thomasvitale.ai.spring; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.document.Document; +import org.springframework.ai.reader.TextReader; +import org.springframework.ai.transformer.splitter.TokenTextSplitter; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +@Component +public class IngestionPipeline { + + private static final Logger logger = LoggerFactory.getLogger(IngestionPipeline.class); + private final VectorStore vectorStore; + + @Value("classpath:documents/story1.md") + Resource textFile1; + + @Value("classpath:documents/story2.txt") + Resource textFile2; + + public IngestionPipeline(VectorStore vectorStore) { + this.vectorStore = vectorStore; + } + + @PostConstruct + public void run() { + List documents = new ArrayList<>(); + + logger.info("Loading .md files as Documents"); + var textReader1 = new TextReader(textFile1); + textReader1.getCustomMetadata().put("location", "North Pole"); + textReader1.setCharset(Charset.defaultCharset()); + documents.addAll(textReader1.get()); + + logger.info("Loading .txt files as Documents"); + var textReader2 = new TextReader(textFile2); + textReader2.getCustomMetadata().put("location", "Italy"); + textReader2.setCharset(Charset.defaultCharset()); + documents.addAll(textReader2.get()); + + logger.info("Creating and storing Embeddings from Documents"); + vectorStore.add(new TokenTextSplitter().split(documents)); + } + +} diff --git a/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/ObservabilityVectorStoresPgVector.java b/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/ObservabilityVectorStoresPgVector.java new file mode 100644 index 0000000..7800c99 --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/src/main/java/com/thomasvitale/ai/spring/ObservabilityVectorStoresPgVector.java @@ -0,0 +1,13 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ObservabilityVectorStoresPgVector { + + public static void main(String[] args) { + SpringApplication.run(ObservabilityVectorStoresPgVector.class, args); + } + +} diff --git a/10-observability/observability-vector-stores-pgvector/src/main/resources/application.yml b/10-observability/observability-vector-stores-pgvector/src/main/resources/application.yml new file mode 100644 index 0000000..b3eb367 --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/src/main/resources/application.yml @@ -0,0 +1,59 @@ +spring: + application: + name: observability-vector-stores-pgvector + ai: + chat: + client: + observations: + include-input: true + observations: + include-completion: true + include-prompt: true + openai: + api-key: ${OPENAI_API_KEY} + chat: + options: + model: gpt-4o-mini + temperature: 0.7 + embedding: + options: + model: text-embedding-3-small + vectorstore: + observations: + include-query-response: true + pgvector: + initialize-schema: true + dimensions: 1536 + index-type: hnsw + +management: + endpoints: + web: + exposure: + include: "*" + metrics: + tags: + service.name: ${spring.application.name} + tracing: + sampling: + probability: 1.0 + otlp: + tracing: + endpoint: http://localhost:4318/v1/traces + +--- + +spring: + config: + activate: + on-profile: ollama + ai: + openai: + base-url: http://localhost:11434 + chat: + options: + model: mistral + temperature: 0.7 + embedding: + options: + model: nomic-embed-text diff --git a/10-observability/observability-vector-stores-pgvector/src/main/resources/documents/story1.md b/10-observability/observability-vector-stores-pgvector/src/main/resources/documents/story1.md new file mode 100644 index 0000000..e9174fd --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/src/main/resources/documents/story1.md @@ -0,0 +1,42 @@ +# The Adventures of Iorek and Pingu + +Iorek was a little polar bear who lived in the Arctic circle. He loved to explore the snowy landscape and +dreamt of one day going on an adventure around the North Pole. One day, he met a penguin named Pingu who +was on a similar quest. They quickly became friends and decided to embark on their journey together. + +Iorek and Pingu set off early in the morning, eager to cover as much ground as possible before nightfall. +The air was crisp and cold, and the snow crunched under their paws as they walked. They chatted excitedly +about their dreams and aspirations, and Iorek told Pingu about his desire to see the Northern Lights. + +As they journeyed onward, they encountered a group of playful seals who were sliding and jumping in the +snow. Iorek and Pingu watched in delight as the seals frolicked and splashed in the water. They even tried +to join in, but their paws kept slipping and they ended up sliding on their stomachs instead. + +After a few hours of walking, Iorek and Pingu came across a cave hidden behind a wall of snow. They +cautiously entered the darkness, their eyes adjusting to the dim light inside. The cave was filled with +glittering ice formations that sparkled like diamonds in the flickering torchlight. + +As they continued their journey, Iorek and Pingu encountered a group of walruses who were lounging on the +ice. They watched in amazement as the walruses lazily rolled over and exposed their tusks for a good +scratch. Pingu even tried to imitate them, but ended up looking more like a clumsy seal than a walrus. + +As the sun began to set, Iorek and Pingu found themselves at the edge of a vast, frozen lake. They gazed +out across the glassy surface, mesmerized by the way the ice glinted in the fading light. They could see +the faint outline of a creature moving beneath the surface, and their hearts raced with excitement. + +Suddenly, a massive narwhal burst through the ice and into the air, its ivory tusk glistening in the +sunset. Iorek and Pingu watched in awe as it soared overhead, its cries echoing across the lake. They felt +as though they were witnessing a magical moment, one that would stay with them forever. + +As the night drew in, Iorek and Pingu settled down to rest in their makeshift camp. They huddled together +for warmth, gazing up at the starry sky above. They chatted about all they had seen and experienced during +their adventure, and Iorek couldn't help but feel grateful for the new friend he had made. + +The next morning, Iorek and Pingu set off once again, determined to explore every inch of the North Pole. +They stumbled upon a hidden cave filled with glittering crystals that sparkled like diamonds in the +sunlight. They marveled at their beauty before continuing on their way. + +As they journeyed onward, Iorek and Pingu encountered many more wonders and adventures. They met a group +of playful reindeer who showed them how to pull sledges across the snow, and even caught a glimpse of the +mythical Loch Ness Monster lurking beneath the icy waters. In the end, their adventure around the North +Pole had been an unforgettable experience, one that they would treasure forever. diff --git a/10-observability/observability-vector-stores-pgvector/src/main/resources/documents/story2.txt b/10-observability/observability-vector-stores-pgvector/src/main/resources/documents/story2.txt new file mode 100644 index 0000000..f5b9322 --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/src/main/resources/documents/story2.txt @@ -0,0 +1,45 @@ +Lucio and Bolosso explore the Alps + +Lucio was a little wolf who lived in the Italian Alps. He loved to explore the rugged landscape and dreamt +of one day going on an adventure around the mountains. One day, he met a brown bear named Balosso who was +on a similar quest. They quickly became friends and decided to embark on their journey together. + +Lucio and Balosso set off early in the morning, eager to cover as much ground as possible before +nightfall. The air was crisp and cool, and the sun shone brightly overhead. They chatted excitedly about +their dreams and aspirations, and Lucio told Balosso about his desire to climb to the top of the highest +peak in the Alps. + +As they journeyed onward, they encountered a group of playful marmots who were scampering across the rocky +terrain. Lucio and Balosso watched in delight as the marmots frolicked and chased each other, their paws +pattering on the stone. They even tried to join in, but their paws kept slipping and they ended up +tumbling onto their backsides. + +After a few hours of walking, Lucio and Balosso came across a hidden glacier nestled between two towering +peaks. They cautiously approached the icy surface, their breath misting in the cold air. The glacier was +covered in intricate patterns and colors, shimmering like a shimmering jewel in the sunlight. + +As they continued their journey, Lucio and Balosso encountered a group of majestic eagles soaring +overhead. They watched in awe as the eagles swooped and dived, their wings spread wide against the blue +sky. Lucio even tried to imitate them, but ended up flapping his ears instead of wings. + +As the sun began to set, Lucio and Balosso found themselves at the foot of the highest peak in the Alps. +They gazed upwards in awe, their hearts pounding with excitement. They could see the faint outline of a +summit visible through the misty veil that surrounded the mountain. + +Lucio and Balosso carefully climbed the steep slope, their claws digging into the rocky surface. The air +grew colder and thinner as they ascended, but they pressed onward, determined to reach the top. Finally, +they reached the summit, where they found a stunning view of the Italian Alps stretching out before them. + +As they gazed out across the landscape, Lucio and Balosso felt an overwhelming sense of pride and +accomplishment. They had faced many challenges along the way, but their friendship had carried them +through. They even spotted a group of rare alpine ibex grazing on the distant slopes, adding to the +adventure's magic. + +As night began to fall, Lucio and Balosso made their way back down the mountain, their paws sore but their +spirits high. They couldn't wait to tell their friends and family about their amazing adventure around the +Italian Alps. Even as they drifted off to sleep, visions of the stunning landscape danced in their minds. + +The next morning, Lucio and Balosso set off once again, eager to explore every inch of the mountain range. +They stumbled upon a hidden valley filled with sparkling streams and towering trees, and even caught a +glimpse of a rare, elusive snow leopard lurking in the shadows. In the end, their adventure around the +Italian Alps had been an unforgettable experience, one that they would treasure forever. diff --git a/10-observability/observability-vector-stores-pgvector/src/test/java/com/thomasvitale/ai/spring/ObservabilityVectorStoresPgVectorTests.java b/10-observability/observability-vector-stores-pgvector/src/test/java/com/thomasvitale/ai/spring/ObservabilityVectorStoresPgVectorTests.java new file mode 100644 index 0000000..f2259f7 --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/src/test/java/com/thomasvitale/ai/spring/ObservabilityVectorStoresPgVectorTests.java @@ -0,0 +1,15 @@ +package com.thomasvitale.ai.spring; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +@Disabled +class ObservabilityVectorStoresPgVectorTests { + + @Test + void contextLoads() { + } + +} diff --git a/10-observability/observability-vector-stores-pgvector/src/test/java/com/thomasvitale/ai/spring/TestObservabilityVectorStoresPgVector.java b/10-observability/observability-vector-stores-pgvector/src/test/java/com/thomasvitale/ai/spring/TestObservabilityVectorStoresPgVector.java new file mode 100644 index 0000000..bfdba56 --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/src/test/java/com/thomasvitale/ai/spring/TestObservabilityVectorStoresPgVector.java @@ -0,0 +1,11 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.SpringApplication; + +public class TestObservabilityVectorStoresPgVector { + + public static void main(String[] args) { + SpringApplication.from(ObservabilityVectorStoresPgVector::main).with(TestcontainersConfiguration.class).run(args); + } + +} diff --git a/10-observability/observability-vector-stores-pgvector/src/test/java/com/thomasvitale/ai/spring/TestcontainersConfiguration.java b/10-observability/observability-vector-stores-pgvector/src/test/java/com/thomasvitale/ai/spring/TestcontainersConfiguration.java new file mode 100644 index 0000000..11a65e0 --- /dev/null +++ b/10-observability/observability-vector-stores-pgvector/src/test/java/com/thomasvitale/ai/spring/TestcontainersConfiguration.java @@ -0,0 +1,38 @@ +package com.thomasvitale.ai.spring; + +import org.springframework.boot.devtools.restart.RestartScope; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Scope; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +import java.time.Duration; + +@TestConfiguration(proxyBeanMethods = false) +class TestcontainersConfiguration { + + @Bean + @RestartScope + @ServiceConnection + PostgreSQLContainer pgvectorContainer() { + return new PostgreSQLContainer<>(DockerImageName.parse("pgvector/pgvector:pg16")); + } + + @Bean + @RestartScope + @Scope("singleton") + @ServiceConnection("otel/opentelemetry-collector-contrib") + GenericContainer lgtmContainer() { + return new GenericContainer<>("docker.io/grafana/otel-lgtm:0.7.1") + .withExposedPorts(3000, 4317, 4318) + .withEnv("OTEL_METRIC_EXPORT_INTERVAL", "500") + .waitingFor(Wait.forLogMessage(".*The OpenTelemetry collector and the Grafana LGTM stack are up and running.*\\s", 1)) + .withStartupTimeout(Duration.ofMinutes(2)) + .withReuse(true); + } + +} diff --git a/README.md b/README.md index a6534f0..3c0c3cf 100644 --- a/README.md +++ b/README.md @@ -98,22 +98,31 @@ _Coming soon_ ### 10. Observability -| Project | Description | -|--------------------------------------------------------------------------------------------------------------------|-------------------------------| -| [observability-models-openai](https://github.com/ThomasVitale/llm-apps-java-spring-ai/tree/main/10-observability/observability-models-openai) | LLM Observability for OpenAI. | +| Project | Description | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------| +| [observability-models-mistral-ai](https://github.com/ThomasVitale/llm-apps-java-spring-ai/tree/main/10-observability/observability-models-mistral-ai) | LLM Observability for Mistral AI. | +| [observability-models-ollama](https://github.com/ThomasVitale/llm-apps-java-spring-ai/tree/main/10-observability/observability-models-ollama) | LLM Observability for Ollama. | +| [observability-models-openai](https://github.com/ThomasVitale/llm-apps-java-spring-ai/tree/main/10-observability/observability-models-openai) | LLM Observability for OpenAI. | +| [observability-vector-stores-pgvector](https://github.com/ThomasVitale/llm-apps-java-spring-ai/tree/main/10-observability/observability-vector-stores-pgvector) | Vector Store Observability for PGVector. | ## References and Additional Resources * [Spring AI Reference Documentation](https://docs.spring.io/spring-ai/reference/index.html) +### Conferences + +* [Introducing Spring AI by Christian Tzolov and Mark Pollack (Spring I/O 2024)](https://www.youtube.com/watch?v=umKbaXsiCOY) +* [Concerto for Java and AI - Building Production-Ready LLM Applications by Thomas Vitale (Spring I/O 2024)](https://www.youtube.com/watch?v=3zTf8NxF-6o) + ### Videos * [Building Intelligent Applications With Spring AI by Dan Vega (JetBrains Live Stream)](https://www.youtube.com/watch?v=x6KmUyPWy2Q) -* [Making your @Beans Intelligent by Mark Pollack (Devoxx Belgium 2023)](https://www.youtube.com/watch?v=7OY9fKVxAFQ) * [Spring AI Series by Dan Vega](https://www.youtube.com/playlist?list=PLZV0a2jwt22uoDm3LNDFvN6i2cAVU_HTH) * [Spring AI Series by Craig Walls](https://www.youtube.com/playlist?list=PLH5OU4wXVJc9aECkMUVPCi8g3pzs8pZ3E) * [Spring Tips: Spring AI by Josh Long](https://www.youtube.com/watch?v=aNKDoiOUo9M) -* [Spring Tips: Spring AI Redux by Josh Long](https://www.youtube.com/watch?v=Q65-Zade25w) +* [Spring Tips: Spring AI Observability by Josh Long](https://www.youtube.com/watch?v=afU8cK0pnpY) +* [Spring Tips: Spring AI Redux by Josh Long and Christian Tzolov](https://www.youtube.com/watch?v=Q65-Zade25w) +* [Spring Tips: Vector Databases with Spring AI by Josh Long](https://www.youtube.com/watch?v=yPu-WV_00Tk) ### Demos diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 326061a..d13ec13 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -7,7 +7,7 @@ repositories { } ext { - set("springBootVersion", '3.3.2') + set("springBootVersion", '3.3.3') set("dependencyManagementVersion", '1.1.6') set("graalvmVersion", '0.10.2') } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e644113..a4b76b9 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a441313..9355b41 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index b740cf1..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 25da30d..9d21a21 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## diff --git a/settings.gradle b/settings.gradle index 962dc9c..8454fe5 100644 --- a/settings.gradle +++ b/settings.gradle @@ -47,4 +47,7 @@ include '08-image-models:image-models-openai' include '09-audio-models:audio-models-speech-openai' include '09-audio-models:audio-models-transcription-openai' +include '10-observability:observability-models-mistral-ai' +include '10-observability:observability-models-ollama' include '10-observability:observability-models-openai' +include '10-observability:observability-vector-stores-pgvector'