From f111aaea0422c1d2db65d1c3fd06ebbdda381b68 Mon Sep 17 00:00:00 2001 From: Mark Pollack Date: Wed, 6 Nov 2024 12:58:52 -0500 Subject: [PATCH] Revert "Add support for Kotlin functions" This reverts commit 9cf66333b5f0e36753f1a74931a59b8ace68bdec. --- spring-ai-core/pom.xml | 31 +++-- .../function/FunctionCallbackContext.java | 81 ++++-------- .../ai/model/function/TypeResolverHelper.java | 119 ++++++++++-------- .../FunctionCallbackWrapperExtensions.kt | 26 ---- .../model/function/TypeResolverHelperIT.java | 13 +- .../FunctionCallbackWrapperExtensionsTests.kt | 34 ----- .../StandaloneWeatherKotlinFunction.kt | 8 -- .../function/TypeResolverHelperKotlinIT.kt | 80 ------------ .../ROOT/pages/api/chat/openai-chat.adoc | 40 +----- .../modules/ROOT/pages/api/functions.adoc | 98 +-------------- .../tool/FunctionCallbackContextKotlinIT.kt | 117 ----------------- .../tool/FunctionCallbackWrapperKotlinIT.kt | 113 ----------------- .../ollama/tool/MockKotlinWeatherService.kt | 80 ------------ 13 files changed, 119 insertions(+), 721 deletions(-) delete mode 100644 spring-ai-core/src/main/kotlin/org/springframework/ai/model/function/FunctionCallbackWrapperExtensions.kt delete mode 100644 spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/FunctionCallbackWrapperExtensionsTests.kt delete mode 100644 spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/StandaloneWeatherKotlinFunction.kt delete mode 100644 spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/TypeResolverHelperKotlinIT.kt delete mode 100644 spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackContextKotlinIT.kt delete mode 100644 spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackWrapperKotlinIT.kt delete mode 100644 spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/MockKotlinWeatherService.kt diff --git a/spring-ai-core/pom.xml b/spring-ai-core/pom.xml index d0aa33b6c..e278f72cc 100644 --- a/spring-ai-core/pom.xml +++ b/spring-ai-core/pom.xml @@ -54,6 +54,18 @@ ${jsonschema.version} + + org.springframework.cloud + spring-cloud-function-context + ${spring-cloud-function-context.version} + + + org.springframework.boot + spring-boot-autoconfigure + + + + org.antlr @@ -126,13 +138,6 @@ ${jackson.version} - - org.jetbrains.kotlin - kotlin-stdlib - ${kotlin.version} - true - - org.springframework.boot @@ -141,16 +146,16 @@ - com.fasterxml.jackson.module - jackson-module-kotlin - ${jackson.version} + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} test - io.mockk - mockk-jvm - 1.13.13 + com.fasterxml.jackson.module + jackson-module-kotlin + ${jackson.version} test diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/function/FunctionCallbackContext.java b/spring-ai-core/src/main/java/org/springframework/ai/model/function/FunctionCallbackContext.java index 3e3b4ac43..ecbb9a4c1 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/function/FunctionCallbackContext.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/function/FunctionCallbackContext.java @@ -16,22 +16,20 @@ package org.springframework.ai.model.function; +import java.lang.reflect.Type; import java.util.function.BiFunction; import java.util.function.Function; import com.fasterxml.jackson.annotation.JsonClassDescription; -import kotlin.jvm.functions.Function1; -import kotlin.jvm.functions.Function2; import org.springframework.ai.chat.model.ToolContext; import org.springframework.beans.BeansException; -import org.springframework.beans.factory.NoSuchBeanDefinitionException; -import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.cloud.function.context.catalog.FunctionTypeUtils; +import org.springframework.cloud.function.context.config.FunctionContextUtils; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.annotation.Description; import org.springframework.context.support.GenericApplicationContext; -import org.springframework.core.ResolvableType; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; @@ -51,7 +49,6 @@ import org.springframework.util.StringUtils; * * @author Christian Tzolov * @author Christopher Smith - * @author Sebastien Deleuze */ public class FunctionCallbackContext implements ApplicationContextAware { @@ -71,19 +68,23 @@ public class FunctionCallbackContext implements ApplicationContextAware { @SuppressWarnings({ "rawtypes", "unchecked" }) public FunctionCallback getFunctionCallback(@NonNull String beanName, @Nullable String defaultDescription) { - BeanDefinition beanDefinition; - try { - beanDefinition = this.applicationContext.getBeanDefinition(beanName); - } - catch (NoSuchBeanDefinitionException ex) { + Type beanType = FunctionContextUtils.findType(this.applicationContext.getBeanFactory(), beanName); + + if (beanType == null) { throw new IllegalArgumentException( - "Functional bean with name " + beanName + " does not exist in the context."); + "Functional bean with name: " + beanName + " does not exist in the context."); } - ResolvableType functionType = beanDefinition.getResolvableType(); - ResolvableType functionInputType = TypeResolverHelper.getFunctionArgumentType(functionType.getType(), 0); + if (!Function.class.isAssignableFrom(FunctionTypeUtils.getRawType(beanType)) + && !BiFunction.class.isAssignableFrom(FunctionTypeUtils.getRawType(beanType))) { + throw new IllegalArgumentException( + "Function call Bean must be of type Function or BiFunction. Found: " + beanType.getTypeName()); + } - Class functionInputClass = functionInputType.toClass(); + Type functionInputType = TypeResolverHelper.getFunctionArgumentType(beanType, 0); + + Class functionInputClass = FunctionTypeUtils.getRawType(functionInputType); + String functionName = beanName; String functionDescription = defaultDescription; if (!StringUtils.hasText(functionDescription)) { @@ -113,40 +114,24 @@ public class FunctionCallbackContext implements ApplicationContextAware { Object bean = this.applicationContext.getBean(beanName); - if (KotlinDelegate.isKotlinFunction(functionType.toClass())) { - return FunctionCallbackWrapper.builder(KotlinDelegate.wrapKotlinFunction(bean)) - .withName(beanName) - .withSchemaType(this.schemaType) - .withDescription(functionDescription) - .withInputType(functionInputClass) - .build(); - } - else if (KotlinDelegate.isKotlinBiFunction(functionType.toClass())) { - return FunctionCallbackWrapper.builder(KotlinDelegate.wrapKotlinBiFunction(bean)) - .withName(beanName) - .withSchemaType(this.schemaType) - .withDescription(functionDescription) - .withInputType(functionInputClass) - .build(); - } - else if (bean instanceof Function function) { + if (bean instanceof Function function) { return FunctionCallbackWrapper.builder(function) - .withName(beanName) + .withName(functionName) .withSchemaType(this.schemaType) .withDescription(functionDescription) .withInputType(functionInputClass) .build(); } - else if (bean instanceof BiFunction) { - return FunctionCallbackWrapper.builder((BiFunction) bean) - .withName(beanName) + else if (bean instanceof BiFunction biFunction) { + return FunctionCallbackWrapper.builder((BiFunction) biFunction) + .withName(functionName) .withSchemaType(this.schemaType) .withDescription(functionDescription) .withInputType(functionInputClass) .build(); } else { - throw new IllegalStateException(); + throw new IllegalArgumentException("Bean must be of type Function"); } } @@ -156,26 +141,4 @@ public class FunctionCallbackContext implements ApplicationContextAware { } - private static class KotlinDelegate { - - public static boolean isKotlinFunction(Class clazz) { - return Function1.class.isAssignableFrom(clazz); - } - - @SuppressWarnings("unchecked") - public static Function wrapKotlinFunction(Object function) { - return t -> ((Function1) function).invoke(t); - } - - public static boolean isKotlinBiFunction(Class clazz) { - return Function2.class.isAssignableFrom(clazz); - } - - @SuppressWarnings("unchecked") - public static BiFunction wrapKotlinBiFunction(Object function) { - return (t, u) -> ((Function2) function).invoke(t, u); - } - - } - } diff --git a/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java b/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java index 0efe2fdcc..8ff8584c4 100644 --- a/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java +++ b/spring-ai-core/src/main/java/org/springframework/ai/model/function/TypeResolverHelper.java @@ -16,22 +16,21 @@ package org.springframework.ai.model.function; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.function.BiFunction; import java.util.function.Function; -import kotlin.jvm.functions.Function1; -import kotlin.jvm.functions.Function2; +import net.jodah.typetools.TypeResolver; -import org.springframework.core.KotlinDetector; -import org.springframework.core.ResolvableType; +import org.springframework.cloud.function.context.catalog.FunctionTypeUtils; /** * A utility class that provides methods for resolving types and classes related to * functions. * * @author Christian Tzolov - * @author Sebastien Dekeuze */ public abstract class TypeResolverHelper { @@ -69,9 +68,12 @@ public abstract class TypeResolverHelper { * @return The class of the specified function argument. */ public static Class getFunctionArgumentClass(Class> functionClass, int argumentIndex) { - ResolvableType resolvableType = ResolvableType.forClass(functionClass).as(Function.class); - return (resolvableType == ResolvableType.NONE ? Object.class - : resolvableType.getGeneric(argumentIndex).toClass()); + Type type = TypeResolver.reify(Function.class, functionClass); + + var argumentType = type instanceof ParameterizedType + ? ((ParameterizedType) type).getActualTypeArguments()[argumentIndex] : Object.class; + + return toRawClass(argumentType); } /** @@ -82,65 +84,80 @@ public abstract class TypeResolverHelper { */ public static Class getBiFunctionArgumentClass(Class> biFunctionClass, int argumentIndex) { - ResolvableType resolvableType = ResolvableType.forClass(biFunctionClass).as(BiFunction.class); - return (resolvableType == ResolvableType.NONE ? Object.class - : resolvableType.getGeneric(argumentIndex).toClass()); + Type type = TypeResolver.reify(BiFunction.class, biFunctionClass); + + Type argumentType = type instanceof ParameterizedType + ? ((ParameterizedType) type).getActualTypeArguments()[argumentIndex] : Object.class; + + return toRawClass(argumentType); + } + + /** + * Returns the input type of a given function class. + * @param functionClass The class of the function. + * @return The input type of the function. + */ + public static Type getFunctionInputType(Class> functionClass) { + return getFunctionArgumentType(functionClass, 0); + } + + /** + * Retrieves the output type of a given function class. + * @param functionClass The function class. + * @return The output type of the function. + */ + public static Type getFunctionOutputType(Class> functionClass) { + return getFunctionArgumentType(functionClass, 1); } /** * Retrieves the type of a specific argument in a given function class. + * @param functionClass The function class. + * @param argumentIndex The index of the argument whose type should be retrieved. + * @return The type of the specified function argument. + */ + public static Type getFunctionArgumentType(Class> functionClass, int argumentIndex) { + Type functionType = TypeResolver.reify(Function.class, functionClass); + return getFunctionArgumentType(functionType, argumentIndex); + } + + /** + * Retrieves the type of a specific argument in a given function type. * @param functionType The function type. * @param argumentIndex The index of the argument whose type should be retrieved. * @return The type of the specified function argument. - * @throws IllegalArgumentException if functionType is not a supported type */ - public static ResolvableType getFunctionArgumentType(Type functionType, int argumentIndex) { + public static Type getFunctionArgumentType(Type functionType, int argumentIndex) { - ResolvableType resolvableType = ResolvableType.forType(functionType); - Class resolvableClass = resolvableType.toClass(); - ResolvableType functionArgumentResolvableType = ResolvableType.NONE; - - if (Function.class.isAssignableFrom(resolvableClass)) { - functionArgumentResolvableType = resolvableType.as(Function.class); - } - else if (BiFunction.class.isAssignableFrom(resolvableClass)) { - functionArgumentResolvableType = resolvableType.as(BiFunction.class); - } - else if (KotlinDetector.isKotlinPresent()) { - if (KotlinDelegate.isKotlinFunction(resolvableClass)) { - functionArgumentResolvableType = KotlinDelegate.adaptToKotlinFunctionType(resolvableType); + // Resolves: https://github.com/spring-projects/spring-ai/issues/726 + if (!(functionType instanceof ParameterizedType)) { + Class functionalClass = FunctionTypeUtils.getRawType(functionType); + // Resolves: https://github.com/spring-projects/spring-ai/issues/1576 + if (BiFunction.class.isAssignableFrom(functionalClass)) { + functionType = TypeResolver.reify(BiFunction.class, (Class>) functionalClass); } - else if (KotlinDelegate.isKotlinBiFunction(resolvableClass)) { - functionArgumentResolvableType = KotlinDelegate.adaptToKotlinBiFunctionType(resolvableType); + else { + functionType = FunctionTypeUtils.discoverFunctionTypeFromClass(functionalClass); } } - if (functionArgumentResolvableType == ResolvableType.NONE) { - throw new IllegalArgumentException( - "Type must be a Function, BiFunction, Function1 or Function2. Found: " + resolvableType); - } + var argumentType = functionType instanceof ParameterizedType + ? ((ParameterizedType) functionType).getActualTypeArguments()[argumentIndex] : Object.class; - return functionArgumentResolvableType.getGeneric(argumentIndex); + return argumentType; } - private static class KotlinDelegate { - - public static boolean isKotlinFunction(Class clazz) { - return Function1.class.isAssignableFrom(clazz); - } - - public static ResolvableType adaptToKotlinFunctionType(ResolvableType resolvableType) { - return resolvableType.as(Function1.class); - } - - public static boolean isKotlinBiFunction(Class clazz) { - return Function2.class.isAssignableFrom(clazz); - } - - public static ResolvableType adaptToKotlinBiFunctionType(ResolvableType resolvableType) { - return resolvableType.as(Function2.class); - } - + /** + * Effectively converts {@link Type} which could be {@link ParameterizedType} to raw + * Class (no generics). + * @param type actual {@link Type} instance + * @return instance of {@link Class} as raw representation of the provided + * {@link Type} + */ + public static Class toRawClass(Type type) { + return type != null + ? TypeResolver.resolveRawClass(type instanceof GenericArrayType ? type : TypeResolver.reify(type), null) + : null; } } diff --git a/spring-ai-core/src/main/kotlin/org/springframework/ai/model/function/FunctionCallbackWrapperExtensions.kt b/spring-ai-core/src/main/kotlin/org/springframework/ai/model/function/FunctionCallbackWrapperExtensions.kt deleted file mode 100644 index d07dcbc2d..000000000 --- a/spring-ai-core/src/main/kotlin/org/springframework/ai/model/function/FunctionCallbackWrapperExtensions.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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.model.function - -/** - * Extension for [FunctionCallbackWrapper.Builder.withInputType] providing a `withInputType()` - * variant. - * - * @author Sebastien Deleuze - */ -inline fun FunctionCallbackWrapper.Builder<*, *>.withInputType() = - withInputType(T::class.java) diff --git a/spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperIT.java b/spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperIT.java index 23796af1c..fb532d9ce 100644 --- a/spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperIT.java +++ b/spring-ai-core/src/test/java/org/springframework/ai/model/function/TypeResolverHelperIT.java @@ -23,9 +23,9 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.function.context.config.FunctionContextUtils; import org.springframework.context.annotation.Bean; import org.springframework.context.support.GenericApplicationContext; @@ -41,11 +41,12 @@ class TypeResolverHelperIT { @ValueSource(strings = { "weatherClassDefinition", "weatherFunctionDefinition", "standaloneWeatherFunction" }) void beanInputTypeResolutionTest(String beanName) { assertThat(this.applicationContext).isNotNull(); - BeanDefinition beanDefinition = this.applicationContext.getBeanDefinition(beanName); - Type beanType = beanDefinition.getResolvableType().getType(); - Class functionInputClass = TypeResolverHelper.getFunctionArgumentType(beanType, 0).getRawClass(); - assertThat(functionInputClass).isNotNull(); - assertThat(functionInputClass.getTypeName()).isEqualTo(WeatherRequest.class.getName()); + Type beanType = FunctionContextUtils.findType(this.applicationContext.getBeanFactory(), beanName); + assertThat(beanType).isNotNull(); + Type functionInputType = TypeResolverHelper.getFunctionArgumentType(beanType, 0); + assertThat(functionInputType).isNotNull(); + assertThat(functionInputType.getTypeName()).isEqualTo(WeatherRequest.class.getName()); + } public record WeatherRequest(String city) { diff --git a/spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/FunctionCallbackWrapperExtensionsTests.kt b/spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/FunctionCallbackWrapperExtensionsTests.kt deleted file mode 100644 index 531b3cc87..000000000 --- a/spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/FunctionCallbackWrapperExtensionsTests.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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.model.function - -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.junit.jupiter.api.Test - -class FunctionCallbackWrapperExtensionsTests { - - private val builder = mockk>() - - @Test - fun withInputType() { - every { builder.withInputType(any>()) } returns builder - builder.withInputType() - verify { builder.withInputType(WeatherRequest::class.java) } - } -} diff --git a/spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/StandaloneWeatherKotlinFunction.kt b/spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/StandaloneWeatherKotlinFunction.kt deleted file mode 100644 index 97ccdbe5c..000000000 --- a/spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/StandaloneWeatherKotlinFunction.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.springframework.ai.model.function - -class StandaloneWeatherKotlinFunction : Function1 { - - override fun invoke(weatherRequest: WeatherRequest): WeatherResponse { - return WeatherResponse(42.0f) - } -} diff --git a/spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/TypeResolverHelperKotlinIT.kt b/spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/TypeResolverHelperKotlinIT.kt deleted file mode 100644 index 5d451d128..000000000 --- a/spring-ai-core/src/test/kotlin/org/springframework/ai/model/function/TypeResolverHelperKotlinIT.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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.model.function - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.SpringBootConfiguration -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.context.annotation.Bean -import org.springframework.context.support.GenericApplicationContext - -@SpringBootTest -class TypeResolverHelperKotlinIT { - - @Autowired - lateinit var applicationContext: GenericApplicationContext - - @ParameterizedTest(name = "{0} : {displayName} ") - @ValueSource(strings = ["weatherClassDefinition", "weatherFunctionDefinition", "standaloneWeatherFunction"]) - fun beanInputTypeResolutionTest(beanName: String) { - assertThat(this.applicationContext).isNotNull() - val beanDefinition = this.applicationContext.getBeanDefinition(beanName) - val beanType = beanDefinition.resolvableType.type - val functionInputClass = TypeResolverHelper.getFunctionArgumentType(beanType, 0).rawClass; - assertThat(functionInputClass).isNotNull(); - assertThat(functionInputClass.typeName).isEqualTo(WeatherRequest::class.java.getName()); - - } - - class Outer { - - class InnerWeatherFunction : Function1 { - - override fun invoke(weatherRequest: WeatherRequest): WeatherResponse { - return WeatherResponse(42.0f) - } - } - } - - @SpringBootConfiguration - open class TypeResolverHelperConfiguration { - - @Bean - open fun weatherClassDefinition(): Outer.InnerWeatherFunction { - return Outer.InnerWeatherFunction(); - } - - @Bean - open fun weatherFunctionDefinition(): Function1 { - return Outer.InnerWeatherFunction(); - } - - @Bean - open fun standaloneWeatherFunction(): StandaloneWeatherKotlinFunction { - return StandaloneWeatherKotlinFunction(); - } - - } - -} - -data class WeatherRequest(val city: String) - -data class WeatherResponse(val temperatureInCelsius: Float) diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc index d23dea9ad..8ee2e8d4f 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat/openai-chat.adoc @@ -270,11 +270,6 @@ NOTE: Adhere to the OpenAI link:https://platform.openai.com/docs/guides/structur You can leverage existing xref::api/structured-output-converter.adoc#_bean_output_converter[BeanOutputConverter] utilities to automatically generate the JSON Schema from your domain objects and later convert the structured response into domain-specific instances: --- -[tabs] -====== -Java:: -+ [source,java] ---- record MathReasoning( @@ -306,41 +301,8 @@ String content = this.response.getResult().getOutput().getContent(); MathReasoning mathReasoning = this.outputConverter.convert(this.content); ---- -Kotlin:: -+ -[source,kotlin] ----- -data class MathReasoning( - @get:JsonProperty(required = true, value = "steps") val steps: Steps, - @get:JsonProperty(required = true, value = "final_answer") val finalAnswer: String) { - data class Steps(@get:JsonProperty(required = true, value = "items") val items: Array) { - - data class Items( - @get:JsonProperty(required = true, value = "explanation") val explanation: String, - @get:JsonProperty(required = true, value = "output") val output: String) - } -} - -val outputConverter = BeanOutputConverter(MathReasoning::class.java) - -val jsonSchema = outputConverter.jsonSchema; - -val prompt = Prompt("how can I solve 8x + 7 = -23", - OpenAiChatOptions.builder() - .withModel(ChatModel.GPT_4_O_MINI) - .withResponseFormat(ResponseFormat(ResponseFormat.Type.JSON_SCHEMA, jsonSchema)) - .build()) - -val response = openAiChatModel.call(prompt) -val content = response.getResult().getOutput().getContent() - -val mathReasoning = outputConverter.convert(content) ----- -====== --- - -NOTE: Ensure you use the `@JsonProperty(required = true,...)` annotation (`@get:JsonProperty(required = true,...)` with Kotlin in order to generate the annotation on the related getters, see link:https://kotlinlang.org/docs/annotations.html#annotation-use-site-targets[related documentation]). +NOTE: Ensure you use the `@JsonProperty(required = true,...)` annotation. This is crucial for generating a schema that accurately marks fields as `required`. Although this is optional for JSON Schema, OpenAI link:https://platform.openai.com/docs/guides/structured-outputs/all-fields-must-be-required[mandates] it for the structured response to function correctly. diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/functions.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/functions.adoc index 21d6b7923..db7d51693 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/functions.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/functions.adoc @@ -56,13 +56,8 @@ When the model needs to answer a question such as `"What’s the weather like in Our function calls some SaaS-based weather service API and returns the weather response back to the model to complete the conversation. In this example, we will use a simple implementation named `MockWeatherService` that hard-codes the temperature for various locations. -The following `MockWeatherService` class represents the weather service API: +The following `MockWeatherService.java` represents the weather service API: --- -[tabs] -====== -Java:: -+ [source,java] ---- public class MockWeatherService implements Function { @@ -76,20 +71,6 @@ public class MockWeatherService implements Function { } } ---- -Kotlin:: -+ -[source,kotlin] ----- -class MockWeatherService : Function1 { - override fun invoke(request: Request) = Response(30.0, Unit.C) -} - -enum class Unit { C, F } -data class Request(val location: String, val unit: Unit) {} -data class Response(val temp: Double, val unit: Unit) {} ----- -====== --- === Registering Functions as Beans @@ -97,18 +78,13 @@ Spring AI provides multiple ways to register custom functions as beans in the Sp We start by describing the most POJO-friendly options. -==== Plain Functions +==== Plain Java Functions In this approach, you define a `@Bean` in your application context as you would any other Spring managed object. Internally, Spring AI `ChatModel` will create an instance of a `FunctionCallbackWrapper` that adds the logic for it being invoked via the AI model. The name of the `@Bean` is used function name. --- -[tabs] -====== -Java:: -+ [source,java] ---- @Configuration @@ -122,63 +98,24 @@ static class Config { } ---- -Kotlin:: -+ -[source,kotlin] ----- -@Configuration -class Config { - - @Bean - @Description("Get the weather in location") // function description - fun currentWeather(): (Request) -> Response = MockWeatherService() - -} ----- -====== --- The `@Description` annotation is optional and provides a function description that helps the model understand when to call the function. It is an important property to set to help the AI model determine what client side function to invoke. Another option for providing the description of the function is to use the `@JsonClassDescription` annotation on the `MockWeatherService.Request`: --- -[tabs] -====== -Java:: -+ [source,java] ---- @Configuration static class Config { - @Bean public Function currentWeather() { // bean name as function name return new MockWeatherService(); } } -@JsonClassDescription("Get the weather in location") // function description +@JsonClassDescription("Get the weather in location") // // function description public record Request(String location, Unit unit) {} ---- -Kotlin:: -+ -[source,kotlin] ----- -@Configuration -class Config { - - @Bean - fun currentWeather(): (Request) -> Response { // bean name as function name - return MockWeatherService() - } -} - -@JsonClassDescription("Get the weather in location") // function description -data class Request(val location: String, val unit: Unit) ----- -====== --- It is a best practice to annotate the request object with information such that the generated JSON schema of that function is as descriptive as possible to help the AI model pick the correct function to invoke. @@ -186,11 +123,6 @@ It is a best practice to annotate the request object with information such that Another way to register a function is to create a `FunctionCallbackWrapper` like this: --- -[tabs] -====== -Java:: -+ [source,java] ---- @Configuration @@ -206,30 +138,6 @@ static class Config { } } ---- -Kotlin:: -+ -[source,kotlin] ----- -import org.springframework.ai.model.function.withInputType - -@Configuration -class Config { - - @Bean - fun weatherFunctionInfo(): FunctionCallback { - - return FunctionCallbackWrapper.builder(MockWeatherService()) - .withName("CurrentWeather") // (1) function name - .withDescription("Get the weather in location") // (2) function description - // (3) Required due to Kotlin SAM conversion beeing an opaque lambda - .withInputType() - .build(); - } -} - ----- -====== --- It wraps the 3rd party `MockWeatherService` function and registers it as a `CurrentWeather` function with the `ChatClient`. It also provides a description (2) and an optional response converter to convert the response into a text as expected by the model. diff --git a/spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackContextKotlinIT.kt b/spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackContextKotlinIT.kt deleted file mode 100644 index 8866b2948..000000000 --- a/spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackContextKotlinIT.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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.autoconfigure.ollama.tool - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.condition.DisabledIf -import org.slf4j.LoggerFactory -import org.springframework.ai.autoconfigure.ollama.BaseOllamaIT -import org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration -import org.springframework.ai.chat.messages.UserMessage -import org.springframework.ai.chat.prompt.Prompt -import org.springframework.ai.model.function.FunctionCallingOptions -import org.springframework.ai.ollama.OllamaChatModel -import org.springframework.ai.ollama.api.OllamaOptions -import org.springframework.boot.autoconfigure.AutoConfigurations -import org.springframework.boot.test.context.runner.ApplicationContextRunner -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Description -import org.testcontainers.junit.jupiter.Testcontainers - -@Testcontainers -@DisabledIf("isDisabled") -class FunctionCallbackContextKotlinIT : BaseOllamaIT() { - - private val logger = LoggerFactory.getLogger(FunctionCallbackContextKotlinIT::class.java) - - private val MODEL_NAME = "qwen2.5:3b" - - val contextRunner = buildConnectionWithModel(MODEL_NAME).let { baseUrl -> - ApplicationContextRunner().withPropertyValues( - "spring.ai.ollama.baseUrl=$baseUrl", - "spring.ai.ollama.chat.options.model=$MODEL_NAME", - "spring.ai.ollama.chat.options.temperature=0.5", - "spring.ai.ollama.chat.options.topK=10" - ) - .withConfiguration(AutoConfigurations.of(OllamaAutoConfiguration::class.java)) - .withUserConfiguration(Config::class.java) - } - - @Test - fun functionCallTest() { - this.contextRunner.run {context -> - - val chatModel = context.getBean(OllamaChatModel::class.java) - - val userMessage = UserMessage( - "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations.") - - val response = chatModel - .call(Prompt(listOf(userMessage), OllamaOptions.builder().withFunction("weatherInfo").build())) - - logger.info("Response: " + response) - - assertThat(response.getResult().output.content).contains("30", "10", "15") - } - } - - @Test - fun functionCallWithPortableFunctionCallingOptions() { - this.contextRunner.run { context -> - - val chatModel = context.getBean(OllamaChatModel::class.java) - - // Test weatherFunction - val userMessage = UserMessage( - "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations.") - - val functionOptions = FunctionCallingOptions.builder() - .withFunction("weatherInfo") - .build() - - val response = chatModel.call(Prompt(listOf(userMessage), functionOptions)); - - logger.info("Response: " + response.getResult().getOutput().getContent()); - - assertThat(response.getResult().output.content).contains("30", "10", "15"); - } - } - - @Configuration - open class Config { - - @Bean - @Description("Find the weather conditions, forecasts, and temperatures for a location, like a city or state.") - open fun weatherInfo(): (KotlinRequest) -> KotlinResponse = { request -> - var temperature = 10.0 - if (request.location.contains("Paris")) { - temperature = 15.0 - } - else if (request.location.contains("Tokyo")) { - temperature = 10.0 - } - else if (request.location.contains("San Francisco")) { - temperature = 30.0 - } - KotlinResponse(temperature, 15.0, 20.0, 2.0, 53, 45, Unit.C); - } - - } -} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackWrapperKotlinIT.kt b/spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackWrapperKotlinIT.kt deleted file mode 100644 index 4af4d38c7..000000000 --- a/spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/FunctionCallbackWrapperKotlinIT.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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.autoconfigure.ollama.tool - -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.condition.DisabledIf -import org.slf4j.LoggerFactory -import org.springframework.ai.autoconfigure.ollama.BaseOllamaIT -import org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration -import org.springframework.ai.chat.messages.UserMessage -import org.springframework.ai.chat.prompt.Prompt -import org.springframework.ai.model.function.FunctionCallback -import org.springframework.ai.model.function.FunctionCallbackWrapper -import org.springframework.ai.model.function.FunctionCallingOptions -import org.springframework.ai.ollama.OllamaChatModel -import org.springframework.ai.ollama.api.OllamaOptions -import org.springframework.boot.autoconfigure.AutoConfigurations -import org.springframework.boot.test.context.runner.ApplicationContextRunner -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.testcontainers.junit.jupiter.Testcontainers - -@Testcontainers -@DisabledIf("isDisabled") -class FunctionCallbackWrapperKotlinIT : BaseOllamaIT() { - - private val logger = LoggerFactory.getLogger(FunctionCallbackWrapperKotlinIT::class.java) - - private val MODEL_NAME = "qwen2.5:3b" - - val contextRunner = buildConnectionWithModel(MODEL_NAME).let { baseUrl -> - ApplicationContextRunner().withPropertyValues( - "spring.ai.ollama.baseUrl=$baseUrl", - "spring.ai.ollama.chat.options.model=$MODEL_NAME", - "spring.ai.ollama.chat.options.temperature=0.5", - "spring.ai.ollama.chat.options.topK=10" - ) - .withConfiguration(AutoConfigurations.of(OllamaAutoConfiguration::class.java)) - .withUserConfiguration(Config::class.java) - } - - @Test - fun functionCallTest() { - this.contextRunner.run {context -> - - val chatModel = context.getBean(OllamaChatModel::class.java) - - val userMessage = UserMessage( - "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations.") - - val response = chatModel - .call(Prompt(listOf(userMessage), OllamaOptions.builder().withFunction("WeatherInfo").build())) - - logger.info("Response: " + response) - - assertThat(response.getResult().output.content).contains("30", "10", "15") - } - } - - @Test - fun functionCallWithPortableFunctionCallingOptions() { - this.contextRunner.run { context -> - - val chatModel = context.getBean(OllamaChatModel::class.java) - - // Test weatherFunction - val userMessage = UserMessage( - "What are the weather conditions in San Francisco, Tokyo, and Paris? Find the temperature in Celsius for each of the three locations.") - - val functionOptions = FunctionCallingOptions.builder() - .withFunction("WeatherInfo") - .build() - - val response = chatModel.call(Prompt(listOf(userMessage), functionOptions)); - - logger.info("Response: " + response.getResult().getOutput().getContent()); - - assertThat(response.getResult().output.content).contains("30", "10", "15"); - } - } - - @Configuration - open class Config { - - @Bean - open fun weatherFunctionInfo(): FunctionCallback { - - return FunctionCallbackWrapper.builder(MockKotlinWeatherService()) - .withName("WeatherInfo") - .withInputType(KotlinRequest::class.java) - .withDescription( - "Find the weather conditions, forecasts, and temperatures for a location, like a city or state.") - .build(); - } - - } -} diff --git a/spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/MockKotlinWeatherService.kt b/spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/MockKotlinWeatherService.kt deleted file mode 100644 index 89795b4d1..000000000 --- a/spring-ai-spring-boot-autoconfigure/src/test/kotlin/org/springframework/ai/autoconfigure/ollama/tool/MockKotlinWeatherService.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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.autoconfigure.ollama.tool - -import com.fasterxml.jackson.annotation.JsonClassDescription -import com.fasterxml.jackson.annotation.JsonInclude -import com.fasterxml.jackson.annotation.JsonInclude.Include -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.annotation.JsonPropertyDescription - -class MockKotlinWeatherService : Function1 { - - override fun invoke(kotlinRequest: KotlinRequest): KotlinResponse { - var temperature = 10.0 - if (kotlinRequest.location.contains("Paris")) { - temperature = 15.0 - } - else if (kotlinRequest.location.contains("Tokyo")) { - temperature = 10.0 - } - else if (kotlinRequest.location.contains("San Francisco")) { - temperature = 30.0 - } - - return KotlinResponse(temperature, 15.0, 20.0, 2.0, 53, 45, Unit.C); - } -} - -/** - * Temperature units. - */ -enum class Unit(val unitName: String) { - - /** - * Celsius. - */ - C("metric"), - /** - * Fahrenheit. - */ - F("imperial"); -} - -/** - * Weather Function request. - */ -@JsonInclude(Include.NON_NULL) -@JsonClassDescription("Weather API request") -data class KotlinRequest(@get:JsonProperty(required = true, value = "location") @get:JsonPropertyDescription("The city and state e.g. San Francisco, CA") val location: String, - @get:JsonProperty(required = true, value = "lat") @get:JsonPropertyDescription("The city latitude") val lat: Double, - @get:JsonProperty(required = true, value = "lon") @get:JsonPropertyDescription("The city longitude") val lon: Double, - @get:JsonProperty(required = true, value = "unit") @get:JsonPropertyDescription("Temperature unit") val unit: Unit) { - -} - -/** - * Weather Function response. - */ -data class KotlinResponse(val temp: Double, - val feels_like: Double, - val temp_min: Double, - val temp_max: Double, - val pressure: Int, - val humidity: Int, - val unit: Unit -)