From a9a2f7bf9f3b7fdf3ddb9a49c7eab0caff901ffb Mon Sep 17 00:00:00 2001 From: John Oliver <1615532+johnoliver@users.noreply.github.com> Date: Fri, 15 Mar 2024 15:30:03 +0000 Subject: [PATCH] Java: Add KernelPluginFactoryTest (#5498) ### Motivation and Context ### Description ### Contribution Checklist - [ ] The code builds clean without any errors or warnings - [ ] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [ ] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone :smile: --- java/semantickernel-api/pom.xml | 29 +++-- .../EmbeddedResourceLoader.java | 13 ++- .../plugin/KernelPluginFactory.java | 106 +++++++++++++----- .../KernelFunctionFromMethod.java | 12 +- .../annotations/DefineKernelFunction.java | 3 +- .../plugin/KernelPluginFactoryTest.java | 101 +++++++++++++++++ .../ExampleFunction/config.json | 24 ++++ .../ExampleFunction/skprompt.txt | 1 + 8 files changed, 249 insertions(+), 40 deletions(-) create mode 100644 java/semantickernel-api/src/test/java/com/microsoft/semantickernel/plugin/KernelPluginFactoryTest.java create mode 100644 java/semantickernel-api/src/test/resources/Plugins/ExamplePlugins/ExampleFunction/config.json create mode 100644 java/semantickernel-api/src/test/resources/Plugins/ExamplePlugins/ExampleFunction/skprompt.txt diff --git a/java/semantickernel-api/pom.xml b/java/semantickernel-api/pom.xml index 930712d9f3..e829e7f7c4 100644 --- a/java/semantickernel-api/pom.xml +++ b/java/semantickernel-api/pom.xml @@ -1,6 +1,7 @@ - + 4.0.0 @@ -66,11 +67,25 @@ 3.3.1 test - - - org.mockito - mockito-junit-jupiter - 5.11.0 - + + + org.mockito + mockito-junit-jupiter + 5.11.0 + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + false + 1 + + + + diff --git a/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/implementation/EmbeddedResourceLoader.java b/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/implementation/EmbeddedResourceLoader.java index 1ad53e14ff..4293388846 100644 --- a/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/implementation/EmbeddedResourceLoader.java +++ b/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/implementation/EmbeddedResourceLoader.java @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. package com.microsoft.semantickernel.implementation; +import com.microsoft.semantickernel.exceptions.SKException; import java.io.BufferedReader; import java.io.File; import java.io.FileNotFoundException; @@ -14,12 +15,16 @@ import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Utility class for loading resources from the classpath or filesystem. */ public class EmbeddedResourceLoader { + private static final Logger LOGGER = LoggerFactory.getLogger(EmbeddedResourceLoader.class); + /** * Loads a file to a string from the classpath using getResourceAsStream * @@ -41,7 +46,8 @@ public class EmbeddedResourceLoader { * @return File content * @throws FileNotFoundException Error in case the file doesn't exist */ - public static String readFile(String fileName, Class clazz, ResourceLocation... locations) + public static String readFile(String fileName, @Nullable Class clazz, + ResourceLocation... locations) throws FileNotFoundException { List locationsList = Arrays.stream(locations) @@ -52,6 +58,10 @@ public class EmbeddedResourceLoader { type -> { switch (type) { case CLASSPATH: + if (clazz == null) { + throw new SKException( + "Resource location CLASSPATH requires a class"); + } return getResourceAsStream(fileName, clazz); case CLASSPATH_ROOT: try (InputStream inputStream = Thread.currentThread() @@ -116,6 +126,7 @@ public class EmbeddedResourceLoader { return bf.lines().collect(Collectors.joining("\n")); } catch (IOException e) { // IGNORE + LOGGER.trace("Failed to load file: " + fileName, e); } return null; } diff --git a/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/plugin/KernelPluginFactory.java b/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/plugin/KernelPluginFactory.java index 08f2c78ab3..620afb87a1 100644 --- a/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/plugin/KernelPluginFactory.java +++ b/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/plugin/KernelPluginFactory.java @@ -6,11 +6,12 @@ import com.microsoft.semantickernel.contextvariables.CaseInsensitiveMap; import com.microsoft.semantickernel.exceptions.SKException; import com.microsoft.semantickernel.implementation.EmbeddedResourceLoader; import com.microsoft.semantickernel.implementation.EmbeddedResourceLoader.ResourceLocation; -import com.microsoft.semantickernel.semanticfunctions.KernelFunction; -import com.microsoft.semantickernel.semanticfunctions.KernelFunctionFromPrompt; import com.microsoft.semantickernel.semanticfunctions.InputVariable; -import com.microsoft.semantickernel.semanticfunctions.OutputVariable; +import com.microsoft.semantickernel.semanticfunctions.KernelFunction; +import com.microsoft.semantickernel.semanticfunctions.KernelFunctionFromMethod; +import com.microsoft.semantickernel.semanticfunctions.KernelFunctionFromPrompt; import com.microsoft.semantickernel.semanticfunctions.KernelPromptTemplateFactory; +import com.microsoft.semantickernel.semanticfunctions.OutputVariable; import com.microsoft.semantickernel.semanticfunctions.PromptTemplate; import com.microsoft.semantickernel.semanticfunctions.PromptTemplateConfig; import com.microsoft.semantickernel.semanticfunctions.PromptTemplateFactory; @@ -97,21 +98,32 @@ public class KernelPluginFactory { annotation.returnDescription(), returnType); - return KernelFunction + KernelFunctionFromMethod.Builder builder = KernelFunction .createFromMethod(method, target) - .withPluginName(pluginName) - .withFunctionName(annotation.name()) - .withDescription(annotation.description()) .withParameters(getParameters(method)) - .withReturnParameter(kernelReturnParameterMetadata) - .build(); + .withReturnParameter(kernelReturnParameterMetadata); + + if (pluginName != null && !pluginName.isEmpty()) { + builder = builder.withPluginName(pluginName); + } + + if (annotation.name() != null && !annotation.name().isEmpty()) { + builder = builder.withFunctionName(annotation.name()); + } + + if (annotation.description() != null && !annotation.description().isEmpty()) { + builder = builder.withDescription(annotation.description()); + } + + return builder.build(); + }).collect(ArrayList::new, (list, it) -> list.add(it), (a, b) -> a.addAll(b)); return createFromFunctions(pluginName, methods); } private static Class getReturnType(DefineKernelFunction annotation, Method method) { - Class returnType; + Class returnType = null; if (annotation.returnType().isEmpty()) { returnType = method.getReturnType(); @@ -126,17 +138,22 @@ public class KernelPluginFactory { // primarily meant to handle void return method.getReturnType(); } + try { returnType = Thread.currentThread().getContextClassLoader() .loadClass(annotation.returnType()); } catch (ClassNotFoundException e) { - returnType = getCommonTypeAlias(method, className); + // ignore + } - if (returnType == null) { - throw new SKException("Could not find return type " + annotation.returnType() - + " is not found on method " + method.getDeclaringClass().getName() + "." - + method.getName()); - } + if (returnType == null) { + returnType = getCommonTypeAlias(method, className); + } + + if (returnType == null) { + throw new SKException("Could not find return type " + annotation.returnType() + + " is not found on method " + method.getDeclaringClass().getName() + "." + + method.getName()); } if (!Publisher.class.isAssignableFrom(method.getReturnType()) @@ -145,7 +162,6 @@ public class KernelPluginFactory { "Return type " + returnType.getName() + " is not assignable from " + method.getReturnType()); } - } return returnType; @@ -167,6 +183,10 @@ public class KernelPluginFactory { returnType = COMMON_CLASS_NAMES.get(className); } + if (returnType != null && Publisher.class.isAssignableFrom(method.getReturnType())) { + return returnType; + } + if (returnType != null && !returnType.isAssignableFrom(method.getReturnType())) { returnType = BOXED_FROM_PRIMATIVE.get(returnType); } @@ -183,6 +203,10 @@ public class KernelPluginFactory { public static Class getTypeForName(String className) { Class clazz = PRIMATIVE_CLASS_NAMES.get(className); + if (clazz == null) { + clazz = COMMON_CLASS_NAMES.get(className); + } + if (clazz != null) { return clazz; } @@ -265,8 +289,10 @@ public class KernelPluginFactory { * @param promptTemplateFactory The factory to use for creating prompt templates. * @return The imported plugin. */ - public static KernelPlugin importPluginFromDirectory(Path parentDirectory, - String pluginDirectoryName, PromptTemplateFactory promptTemplateFactory) { + public static KernelPlugin importPluginFromDirectory( + Path parentDirectory, + String pluginDirectoryName, + @Nullable PromptTemplateFactory promptTemplateFactory) { // Verify.ValidSkillName(pluginDirectoryName); File pluginDir = new File(parentDirectory.toFile(), pluginDirectoryName); @@ -312,7 +338,7 @@ public class KernelPluginFactory { } private static KernelFunction getKernelFunction( - PromptTemplateFactory promptTemplateFactory, + @Nullable PromptTemplateFactory promptTemplateFactory, File configPath, File promptPath) throws IOException { @@ -334,7 +360,7 @@ public class KernelPluginFactory { @SuppressWarnings("unchecked") private static KernelFunction getKernelFunction( - PromptTemplateFactory promptTemplateFactory, + @Nullable PromptTemplateFactory promptTemplateFactory, PromptTemplateConfig config, String template) { PromptTemplate promptTemplate; @@ -360,6 +386,26 @@ public class KernelPluginFactory { .build(); } + /** + * @param parentDirectory The parent directory containing the plugin directories. + * @param pluginDirectoryName The name of the plugin directory. + * @param functionName The name of the function to import. + * @param promptTemplateFactory The factory to use for creating prompt templates. + * @return The imported plugin. + * @see KernelPluginFactory#importPluginFromResourcesDirectory(String, String, String, + * PromptTemplateFactory, Class) + */ + @Nullable + public static KernelPlugin importPluginFromResourcesDirectory( + String parentDirectory, + String pluginDirectoryName, + String functionName, + @Nullable PromptTemplateFactory promptTemplateFactory) { + return importPluginFromResourcesDirectory(parentDirectory, pluginDirectoryName, + functionName, + promptTemplateFactory, null); + } + /** * Imports a plugin from a resource directory, which may be on the classpath or filesystem. The * directory should contain subdirectories, each of which contains a prompt template and a @@ -370,7 +416,8 @@ public class KernelPluginFactory { * @param pluginDirectoryName The name of the plugin directory. * @param functionName The name of the function to import. * @param promptTemplateFactory The factory to use for creating prompt templates. - * @param clazz The class to use for loading resources. + * @param clazz The class to use for loading resources. If null, the classloader + * will be used. * @return The imported plugin. */ @Nullable @@ -378,8 +425,8 @@ public class KernelPluginFactory { String parentDirectory, String pluginDirectoryName, String functionName, - PromptTemplateFactory promptTemplateFactory, - Class clazz) { + @Nullable PromptTemplateFactory promptTemplateFactory, + @Nullable Class clazz) { String template = getTemplatePrompt(parentDirectory, pluginDirectoryName, functionName, clazz); @@ -409,7 +456,7 @@ public class KernelPluginFactory { String pluginDirectory, String pluginName, String functionName, - Class clazz) { + @Nullable Class clazz) { String promptFileName = pluginDirectory + File.separator + pluginName + File.separator + functionName + File.separator + PROMPT_FILE; @@ -423,8 +470,13 @@ public class KernelPluginFactory { } } - private static String getFileContents(String file, Class clazz) + private static String getFileContents(String file, @Nullable Class clazz) throws FileNotFoundException { + if (clazz == null) { + return EmbeddedResourceLoader.readFile(file, null, ResourceLocation.CLASSPATH_ROOT, + ResourceLocation.FILESYSTEM); + } + return EmbeddedResourceLoader.readFile(file, clazz, ResourceLocation.CLASSPATH_ROOT, ResourceLocation.CLASSPATH, ResourceLocation.FILESYSTEM); } @@ -433,7 +485,7 @@ public class KernelPluginFactory { private static PromptTemplateConfig getPromptTemplateConfig( String pluginDirectory, String pluginName, String functionName, - Class clazz) { + @Nullable Class clazz) { String configFileName = pluginDirectory + File.separator + pluginName + File.separator + functionName + File.separator + CONFIG_FILE; diff --git a/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/semanticfunctions/KernelFunctionFromMethod.java b/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/semanticfunctions/KernelFunctionFromMethod.java index 983dfce289..e2acdee0ee 100644 --- a/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/semanticfunctions/KernelFunctionFromMethod.java +++ b/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/semanticfunctions/KernelFunctionFromMethod.java @@ -91,7 +91,7 @@ public class KernelFunctionFromMethod extends KernelFunction { MethodDetails methodDetails = getMethodDetails(functionName, method, target); - if (description == null) { + if (description == null || description.isEmpty()) { description = methodDetails.getDescription(); } @@ -123,11 +123,15 @@ public class KernelFunctionFromMethod extends KernelFunction { String description = null; String returnDescription = null; if (annotation != null) { - description = annotation.description(); - returnDescription = annotation.returnDescription(); + if (!annotation.description().isEmpty()) { + description = annotation.description(); + } + if (!annotation.returnDescription().isEmpty()) { + returnDescription = annotation.returnDescription(); + } } - if (functionName == null) { + if (functionName == null || functionName.isEmpty()) { functionName = method.getName(); } diff --git a/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/semanticfunctions/annotations/DefineKernelFunction.java b/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/semanticfunctions/annotations/DefineKernelFunction.java index 74d933af96..8df2dc14c1 100644 --- a/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/semanticfunctions/annotations/DefineKernelFunction.java +++ b/java/semantickernel-api/src/main/java/com/microsoft/semantickernel/semanticfunctions/annotations/DefineKernelFunction.java @@ -33,7 +33,8 @@ public @interface DefineKernelFunction { /** * The fully qualified class name of the return value of the function, for example, * "java.lang.String". If this parameter is not provided, the model will attempt to infer the - * return type from the method signature. + * return type from the method signature. For async methods provide the return from the async + * method, i.e. if the method returns a Mono<String> then the returnType should be "java.lang.String". * * @return the fully qualified class name of the return value of the function */ diff --git a/java/semantickernel-api/src/test/java/com/microsoft/semantickernel/plugin/KernelPluginFactoryTest.java b/java/semantickernel-api/src/test/java/com/microsoft/semantickernel/plugin/KernelPluginFactoryTest.java new file mode 100644 index 0000000000..1203d01f2e --- /dev/null +++ b/java/semantickernel-api/src/test/java/com/microsoft/semantickernel/plugin/KernelPluginFactoryTest.java @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft. All rights reserved. +package com.microsoft.semantickernel.plugin; + +import com.microsoft.semantickernel.semanticfunctions.KernelFunction; +import com.microsoft.semantickernel.semanticfunctions.annotations.DefineKernelFunction; +import com.microsoft.semantickernel.semanticfunctions.annotations.KernelFunctionParameter; +import java.nio.file.Paths; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +public class KernelPluginFactoryTest { + + public static class TestPlugin { + + @DefineKernelFunction + public String testFunction( + @KernelFunctionParameter(name = "input") String input) { + return "test" + input; + } + + @DefineKernelFunction(returnType = "int") + public Mono asyncTestFunction( + @KernelFunctionParameter(name = "input") String input) { + return Mono.just(1); + } + } + + @Test + public void createFromObjectTest() { + KernelPlugin plugin = KernelPluginFactory.createFromObject( + new TestPlugin(), + "test"); + + Assertions.assertNotNull(plugin); + Assertions.assertEquals(plugin.getName(), "test"); + Assertions.assertEquals(plugin.getFunctions().size(), 2); + + KernelFunction testFunction = plugin.getFunctions() + .get("testFunction"); + + Assertions.assertNotNull(testFunction); + Assertions.assertNotNull(testFunction.getMetadata().getOutputVariableType()); + Assertions.assertEquals(testFunction.getMetadata().getOutputVariableType().getType(), + String.class); + Assertions.assertEquals(testFunction.getMetadata().getParameters().size(), 1); + Assertions.assertEquals(testFunction.getMetadata().getParameters().get(0).getType(), + String.class.getName()); + + } + + @Test + public void importPluginFromDirectoryTest() { + KernelPlugin plugin = KernelPluginFactory.importPluginFromDirectory( + Paths.get("./src/test/resources/Plugins"), + "ExamplePlugins", + null); + + Assertions.assertNotNull(plugin); + Assertions.assertEquals(plugin.getName(), "ExamplePlugins"); + Assertions.assertEquals(plugin.getFunctions().size(), 1); + + KernelFunction testFunction = plugin.getFunctions() + .get("examplefunction"); + + Assertions.assertNotNull(testFunction); + Assertions.assertNotNull(testFunction.getMetadata().getOutputVariableType()); + Assertions.assertEquals(testFunction.getMetadata().getOutputVariableType().getType(), + String.class); + Assertions.assertEquals(testFunction.getMetadata().getParameters().size(), 1); + Assertions.assertEquals(testFunction.getMetadata().getParameters().get(0).getTypeClass(), + String.class); + + } + + @Test + public void importPluginFromResourcesDirectoryTest() { + KernelPlugin plugin = KernelPluginFactory.importPluginFromResourcesDirectory( + "src/test/resources/Plugins", + "ExamplePlugins", + "ExampleFunction", + null); + + Assertions.assertNotNull(plugin); + Assertions.assertEquals(plugin.getName(), "ExamplePlugins"); + Assertions.assertEquals(plugin.getFunctions().size(), 1); + + KernelFunction testFunction = plugin.getFunctions() + .get("examplefunction"); + + Assertions.assertNotNull(testFunction); + Assertions.assertNotNull(testFunction.getMetadata().getOutputVariableType()); + Assertions.assertEquals(testFunction.getMetadata().getOutputVariableType().getType(), + String.class); + Assertions.assertEquals(testFunction.getMetadata().getParameters().size(), 1); + Assertions.assertEquals(testFunction.getMetadata().getParameters().get(0).getTypeClass(), + String.class); + + } + +} diff --git a/java/semantickernel-api/src/test/resources/Plugins/ExamplePlugins/ExampleFunction/config.json b/java/semantickernel-api/src/test/resources/Plugins/ExamplePlugins/ExampleFunction/config.json new file mode 100644 index 0000000000..f8f0a42d7b --- /dev/null +++ b/java/semantickernel-api/src/test/resources/Plugins/ExamplePlugins/ExampleFunction/config.json @@ -0,0 +1,24 @@ +{ + "schema": 1, + "description": "Sample plugin loaded via resources", + "execution_settings": { + "default": { + "max_tokens": 150, + "temperature": 0.9, + "top_p": 0.0, + "presence_penalty": 0.6, + "frequency_penalty": 0.0 + } + }, + "input_variables": [ + { + "name": "prompt", + "type": "string", + "description": "The prompt to generate text from" + } + ], + "output_variables": { + "type": "string", + "description": "The generated text" + } +} \ No newline at end of file diff --git a/java/semantickernel-api/src/test/resources/Plugins/ExamplePlugins/ExampleFunction/skprompt.txt b/java/semantickernel-api/src/test/resources/Plugins/ExamplePlugins/ExampleFunction/skprompt.txt new file mode 100644 index 0000000000..ddd3f836d3 --- /dev/null +++ b/java/semantickernel-api/src/test/resources/Plugins/ExamplePlugins/ExampleFunction/skprompt.txt @@ -0,0 +1 @@ +This is an example \ No newline at end of file