Add support for values params for functions (#65)

### Motivation and Context

1. When writing a prompt one might want to use the symbols `{{` and `}}`
without triggering the template engine.
2. When invoking functions, one might want to pass a value rather than a
variable, e.g. `{{ recall "my preferred browser" }}`.
3. Semantic functions might want to call other semantic functions.

### Description

This PR adds support for "values", so special syntax can be embedded,
and so that functions can be invoked with predefined values without the
need to create a temporary variable.

E.g.

```
{{ "{{" }} and {{ "}}" }} are special SK sequences.
```
```
... {{ read 'c:\documents\ai\file.txt' }} ...
```
```
... {{ "quotes' \"escaping\" example" }} ...
```
```
... {{ 'quotes\' "escaping" example' }} ...
```
```
{{ 'two special chars \\\' here' }}
```

Added missing tests for all the template language, including end to end
integration tests covering several edge cases.

Also: semantic functions can now call other semantic functions. The
previous implementation would allow semantic functions to call only
native functions.
This commit is contained in:
Devis Lucato
2023-03-14 17:46:34 -07:00
committed by GitHub
parent 245e8ddb7f
commit 4250bfb8bd
37 changed files with 2396 additions and 575 deletions
+129 -10
View File
@@ -38,6 +38,10 @@ you can write:
This will produce a greeting with the user's name.
Spaces are ignored, so if you find it more readable, you can also write:
Hello {{ $name }}, welcome to Semantic Kernel!
## Function calls
To call an external function and embed the result in your text, use the
@@ -57,26 +61,31 @@ For instance, the code above is equivalent to:
## Function parameters
To call an external function and pass a parameter to it, use the
`{namespace.functionName $varName}` syntax.
`{{namespace.functionName $varName}}` and
`{{namespace.functionName "value"}}` syntax.
For example, if you want to pass a different input to the weather forecast
function, you can write:
The weather today in {{$city}} is {weather.getForecast $city}.
The weather today in {{$region}} is {weather.getForecast $region}.
The weather today in {{$city}} is {{weather.getForecast $city}}.
The weather today in Schio is {{weather.getForecast "Schio"}}.
This will produce two sentences with the weather forecast for two different
locations, using the city stored in the `city` variable and the region name
stored in the `region` variable.
locations, using the city stored in the _`city`_ **variable** and the _"Schio"_
location **value** hardcoded in the prompt template.
## Design Principles
The template language uses of the `$` symbol on purpose, to clearly distinguish
between variables, which are retrieved from local temporary memory, and
functions that retrieve content executing some code.
The template language is designed to be simple and fast to render, allowing
to create functions with a simple text editor, reducing special syntax to a
minimum, and minimizing edge cases.
Branching features such as "if", "for", and code blocks are not part of SK's
The template language uses the **«`$`»** symbol on purpose, to clearly distinguish
between function calls that retrieve content executing some code, from variables,
which are replaced with data from the local temporary memory.
Branching features such as _"if"_, _"for"_, and code blocks are not part of SK's
template language. This reflects SK's design principle of using natural language
as much as possible, with a clear separation from conventional programming code.
as much as possible, with a clear separation from traditional programming code.
By using a simple language, the kernel can also avoid complex parsing and
external dependencies, resulting in a fast and memory efficient processing.
@@ -91,6 +100,7 @@ template, using the syntax described.
```
My name: {{msgraph.GetMyName}}
My email: {{msgraph.GetMyEmailAddress}}
My hobbies: {{memory.recall "my hobbies"}}
Recipient: {{$recipient}}
Email to reply to:
=========
@@ -132,3 +142,112 @@ async Task<string> GenResponseToEmailAsync(
}
}
```
# Notes about special chars
Semantic function templates are text files, so there is no need to escape special chars
like new lines and tabs. However, there are two cases that require a special syntax:
1. Including double curly braces in the prompt templates
2. Passing to functions hardcoded values that include quotes
## Prompts needing double curly braces
Double curly braces have a special use case, they are used to inject variables,
values, and functions into templates.
If you need to include the **`{{`** and **`}}`** sequences in your prompts, which
could trigger special rendering logic, the best solution is to use string values
enclosed in quotes, like `{{ "{{" }}` and `{{ "}}" }}`
For example:
{{ "{{" }} and {{ "}}" }} are special SK sequences.
will render to:
{{ and }} are special SK sequences.
## Values that include quotes, and escaping
Values can be enclosed using **single quotes** and **double quotes**.
To avoid the need for special syntax, when working with a value that contains
_single quotes_, we recommend wrapping the value with _double quotes_. Similarly,
when using a value that contains _double quotes_, wrap the value with _single quotes_.
For example:
...text... {{ functionName "one 'quoted' word" }} ...text...
...text... {{ functionName 'one "quoted" word' }} ...text...
For those cases where the value contains both single and double quotes, you will
need _escaping_, using the special **«`\`»** symbol.
When using double quotes around a value, use **«`\"`»** to include a double quote
symbol inside the value:
... {{ "quotes' \"escaping\" example" }} ...
and similarly, when using single quotes, use **«`\'`»** to include a single quote
inside the value:
... {{ 'quotes\' "escaping" example' }} ...
Both are rendered to:
... quotes' "escaping" example ...
Note that for consistency, the sequences **«`\'`»** and **«`\"`»** do always render
to **«`'`»** and **«`"`»**, even when escaping might not be required.
For instance:
... {{ 'no need to \"escape" ' }} ...
is equivalent to:
... {{ 'no need to "escape" ' }} ...
and both render to:
... no need to "escape" ...
In case you may need to render a backslash in front of a quote, since **«`\`»**
is a special char, you will need to escape it too, and use the special sequences
**«`\\\'`»** and **«`\\\"`»**.
For example:
{{ 'two special chars \\\' here' }}
is rendered to:
two special chars \' here
Similarly to single and double quotes, the symbol **«`\`»** doesn't always need
to be escaped. However, for consistency, it can be escaped even when not required.
For instance:
... {{ 'c:\\documents\\ai' }} ...
is equivalent to:
... {{ 'c:\documents\ai' }} ...
and both are rendered to:
... c:\documents\ai ...
Lastly, backslashes have a special meaning only when used in front of
**«`'`»**, **«`"`»** and **«`\`»**.
In all other cases, the backslash character has no impact and is rendered as is.
For example:
{{ "nothing special about these sequences: \0 \n \t \r \foo" }}
is rendered to:
nothing special about these sequences: \0 \n \t \r \foo
+7
View File
@@ -47,5 +47,12 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageVersion Include="xunit.analyzers" Version="1.1.0" />
<PackageReference Include="xunit.analyzers" />
<PackageVersion Include="Moq.Analyzers" Version="0.0.9" />
<PackageReference Include="Moq.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+1
View File
@@ -172,6 +172,7 @@ public void It$SOMENAME$()
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DEC4D140B393B04F8A18C0913BDE0592/Shortcut/@EntryValue">copy</s:String>
<s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=DEC4D140B393B04F8A18C0913BDE0592/Text/@EntryValue">// Copyright (c) Microsoft. All rights reserved.
</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=arrivederci/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=awaiters/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=davinci/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Joinable/@EntryIndexedValue">True</s:Boolean>
@@ -37,6 +37,9 @@
<None Update="testsettings.development.json" Condition="Exists('testsettings.development.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<Content Include="TemplateLanguage\tests.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,217 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.SkillDefinition;
using Microsoft.SemanticKernel.TemplateEngine;
using Xunit;
using Xunit.Abstractions;
namespace SemanticKernel.IntegrationTests.TemplateLanguage;
#pragma warning disable VSTHRD103 // ok to use WriteLine synchronously
#pragma warning disable CA1849 // ok to use WriteLine synchronously
public sealed class PromptTemplateEngineTests : IDisposable
{
public PromptTemplateEngineTests(ITestOutputHelper output)
{
this._logger = new RedirectOutput(output);
this._target = new PromptTemplateEngine();
}
[Fact]
public async Task ItSupportsVariablesAsync()
{
// Arrange
const string input = "template tests";
const string winner = "SK";
const string template = "And the winner\n of {{$input}} \nis: {{ $winner }}!";
var kernel = Kernel.Builder.Build();
var context = kernel.CreateNewContext();
context["input"] = input;
context["winner"] = winner;
// Act
var result = await this._target.RenderAsync(template, context);
// Assert
var expected = template
.Replace("{{$input}}", input, StringComparison.OrdinalIgnoreCase)
.Replace("{{ $winner }}", winner, StringComparison.OrdinalIgnoreCase);
Assert.Equal(expected, result);
}
[Fact]
public async Task ItSupportsValuesAsync()
{
// Arrange
const string template = "And the winner\n of {{'template\ntests'}} \nis: {{ \"SK\" }}!";
const string expected = "And the winner\n of template\ntests \nis: SK!";
var kernel = Kernel.Builder.Build();
var context = kernel.CreateNewContext();
// Act
var result = await this._target.RenderAsync(template, context);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public async Task ItAllowsToPassVariablesToFunctionsAsync()
{
// Arrange
const string template = "== {{my.check123 $call}} ==";
var kernel = Kernel.Builder.Build();
kernel.ImportSkill(new MySkill(), "my");
var context = kernel.CreateNewContext();
context["call"] = "123";
// Act
var result = await this._target.RenderAsync(template, context);
// Assert
Assert.Equal("== 123 ok ==", result);
}
[Fact]
public async Task ItAllowsToPassValuesToFunctionsAsync()
{
// Arrange
const string template = "== {{my.check123 '234'}} ==";
var kernel = Kernel.Builder.Build();
kernel.ImportSkill(new MySkill(), "my");
var context = kernel.CreateNewContext();
// Act
var result = await this._target.RenderAsync(template, context);
// Assert
Assert.Equal("== 234 != 123 ==", result);
}
[Fact]
public async Task ItAllowsToPassEscapedValues1ToFunctionsAsync()
{
// Arrange
const char ESC = '\\';
string template = "== {{my.check123 'a" + ESC + "'b'}} ==";
var kernel = Kernel.Builder.Build();
kernel.ImportSkill(new MySkill(), "my");
var context = kernel.CreateNewContext();
// Act
var result = await this._target.RenderAsync(template, context);
// Assert
Assert.Equal("== a'b != 123 ==", result);
}
[Fact]
public async Task ItAllowsToPassEscapedValues2ToFunctionsAsync()
{
// Arrange
const char ESC = '\\';
string template = "== {{my.check123 \"a" + ESC + "\"b\"}} ==";
var kernel = Kernel.Builder.Build();
kernel.ImportSkill(new MySkill(), "my");
var context = kernel.CreateNewContext();
// Act
var result = await this._target.RenderAsync(template, context);
// Assert
Assert.Equal("== a\"b != 123 ==", result);
}
[Theory]
[MemberData(nameof(GetTemplateLanguageTests))]
public async Task ItHandleEdgeCasesAsync(string template, string expectedResult)
{
// Arrange
var kernel = Kernel.Builder.Build();
kernel.ImportSkill(new MySkill());
// Act
this._logger.WriteLine("template: " + template);
this._logger.WriteLine("expected: " + expectedResult);
if (expectedResult.StartsWith("ERROR", StringComparison.InvariantCultureIgnoreCase))
{
await Assert.ThrowsAsync<TemplateException>(
async () => await this._target.RenderAsync(template, kernel.CreateNewContext()));
}
else
{
var result = await this._target.RenderAsync(template, kernel.CreateNewContext());
this._logger.WriteLine(" result: " + result);
// Assert
Assert.Equal(expectedResult, result);
}
}
public static IEnumerable<object[]> GetTemplateLanguageTests()
{
return GetTestData("TemplateLanguage/tests.txt");
}
public class MySkill
{
[SKFunction("This is a test")]
[SKFunctionName("check123")]
public string MyFunction(string input)
{
return input == "123" ? "123 ok" : input + " != 123";
}
[SKFunction("This is a test")]
[SKFunctionName("asis")]
public string MyFunction2(string input)
{
return input;
}
}
#region internals
private readonly RedirectOutput _logger;
private readonly PromptTemplateEngine _target;
private static IEnumerable<string[]> GetTestData(string file)
{
if (!File.Exists(file)) { Assert.Fail("File not found: " + file); }
var content = File.ReadLines(file);
var key = string.Empty;
foreach (string value in content)
{
if (string.IsNullOrEmpty(value) || value.StartsWith('#')) { continue; }
if (string.IsNullOrEmpty(key))
{
key = value;
}
else
{
yield return new string[] { key, value };
key = string.Empty;
}
}
}
public void Dispose()
{
this._logger.Dispose();
}
#endregion
}
#pragma warning restore VSTHRD103
#pragma warning restore CA1849
@@ -0,0 +1,73 @@
# About this file:
# - The file is used by PromptTemplateEngineTests.cs to test SK template language.
# - By using a TXT file there is no ambiguity caused by C#/Python escaping syntax.
# - Empty lines and lines starting with "#" are ignored.
# - Lines are NOT trimmed, there might be empty spaces at the end on purpose.
# - The file contains multiple test cases.
# - Each test case consists of two lines:
# - line 1: the template to render
# - line 2: the expected result after rendering
# - If a template is invalid, line 2 contains the value "ERROR", e.g. a TemplateException is expected.
""
""
{}
{}
{{}}
{{}}
.{{asis}}.
..
a{{asis ''}}b
ab
{{asis 'a'}}
a
{{ asis 'foo' }}
foo
# The second quote means the value is never closed, hiding the closing brackets
# turning the entire string as a static text
{{ asis 'foo\' }}
{{ asis 'foo\' }}
{{ asis 'f\'11' }}
f'11
{{ asis "f\\\'22" }}
f\'22
# The last quote hides the closing }}
{{ call 'f\\'33" }}
{{ call 'f\\'33" }}
# \ is escaped but the second quote is not, terminating the string
# After the string is terminated the <x> token is invalid
{{ call 'f\\'x }}
ERROR
# \ is escaped but the second quote is not, terminating the string
# After the string is terminated the <xy> token is invalid
{{ call 'f\\'xy }}
ERROR
{{ "{{" }} and {{ "}}" }} x
{{ and }} x
{{ " nothing special about these sequences: \ \0 \n \t \r \foo" }}
nothing special about these sequences: \ \0 \n \t \r \foo
1{{ '\\' }}2
1\2
# Even number of escaped \
{{ "\\\\\\\\" }}
\\\\
# Odd number of escaped \
{{ "\\\\\\\\\\" }}
\\\\\
@@ -1,9 +1,11 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.SemanticKernel.AI;
using Microsoft.SemanticKernel.Memory;
using Microsoft.SemanticKernel.Orchestration;
@@ -31,7 +33,7 @@ public class CodeBlockTests
{
// Arrange
var context = new SKContext(new ContextVariables(), NullMemory.Instance, this._skills.Object, this._log.Object);
this._skills.Setup(x => x.HasNativeFunction("functionName")).Returns(false);
this._skills.Setup(x => x.HasFunction("functionName")).Returns(false);
var target = new CodeBlock("functionName", this._log.Object);
// Act
@@ -50,8 +52,8 @@ public class CodeBlockTests
function
.Setup(x => x.InvokeAsync(It.IsAny<SKContext?>(), It.IsAny<CompleteRequestSettings?>(), It.IsAny<ILogger?>(), It.IsAny<CancellationToken?>()))
.Throws(new RuntimeWrappedException("error"));
this._skills.Setup(x => x.HasNativeFunction("functionName")).Returns(true);
this._skills.Setup(x => x.GetNativeFunction("functionName")).Returns(function.Object);
this._skills.Setup(x => x.HasFunction("functionName")).Returns(true);
this._skills.Setup(x => x.GetFunction("functionName")).Returns(function.Object);
var target = new CodeBlock("functionName", this._log.Object);
// Act
@@ -60,4 +62,234 @@ public class CodeBlockTests
// Assert
Assert.Equal(TemplateException.ErrorCodes.RuntimeError, exception.ErrorCode);
}
[Fact]
public void ItHasTheCorrectType()
{
// Act
var target = new CodeBlock("", NullLogger.Instance);
// Assert
Assert.Equal(BlockTypes.Code, target.Type);
}
[Fact]
public void ItTrimsSpaces()
{
// Act + Assert
Assert.Equal("aa", new CodeBlock(" aa ", NullLogger.Instance).Content);
}
[Fact]
public void ItChecksValidityOfInternalBlocks()
{
// Arrange
var validBlock1 = new FunctionIdBlock("x");
var validBlock2 = new ValBlock("''");
var invalidBlock = new VarBlock("");
// Act
var codeBlock1 = new CodeBlock(new List<Block> { validBlock1, validBlock2 }, "", NullLogger.Instance);
var codeBlock2 = new CodeBlock(new List<Block> { validBlock1, invalidBlock }, "", NullLogger.Instance);
// Assert
Assert.True(codeBlock1.IsValid(out _));
Assert.False(codeBlock2.IsValid(out _));
}
[Fact]
public void ItRequiresAValidFunctionCall()
{
// Arrange
var funcId = new FunctionIdBlock("funcName");
var valBlock = new ValBlock("'value'");
var varBlock = new VarBlock("$var");
// Act
var codeBlock1 = new CodeBlock(new List<Block> { funcId, valBlock }, "", NullLogger.Instance);
var codeBlock2 = new CodeBlock(new List<Block> { funcId, varBlock }, "", NullLogger.Instance);
var codeBlock3 = new CodeBlock(new List<Block> { funcId, funcId }, "", NullLogger.Instance);
var codeBlock4 = new CodeBlock(new List<Block> { funcId, varBlock, varBlock }, "", NullLogger.Instance);
// Assert
Assert.True(codeBlock1.IsValid(out _));
Assert.True(codeBlock2.IsValid(out _));
// Assert - Can't pass a function to a function
Assert.False(codeBlock3.IsValid(out _));
// Assert - Can't pass more than one param
Assert.False(codeBlock4.IsValid(out _));
}
[Fact]
public async Task ItRendersCodeBlockConsistingOfJustAVarBlock1Async()
{
// Arrange
var variables = new ContextVariables { ["varName"] = "foo" };
var context = new SKContext(variables, NullMemory.Instance, null, NullLogger.Instance);
// Act
var codeBlock = new CodeBlock("$varName", NullLogger.Instance);
var result = await codeBlock.RenderCodeAsync(context);
// Assert
Assert.Equal("foo", result);
}
[Fact]
public async Task ItRendersCodeBlockConsistingOfJustAVarBlock2Async()
{
// Arrange
var variables = new ContextVariables { ["varName"] = "bar" };
var context = new SKContext(variables, NullMemory.Instance, null, NullLogger.Instance);
var varBlock = new VarBlock("$varName");
// Act
var codeBlock = new CodeBlock(new List<Block> { varBlock }, "", NullLogger.Instance);
var result = await codeBlock.RenderCodeAsync(context);
// Assert
Assert.Equal("bar", result);
}
[Fact]
public async Task ItRendersCodeBlockConsistingOfJustAValBlock1Async()
{
// Arrange
var context = new SKContext(new ContextVariables(), NullMemory.Instance, null, NullLogger.Instance);
// Act
var codeBlock = new CodeBlock("'ciao'", NullLogger.Instance);
var result = await codeBlock.RenderCodeAsync(context);
// Assert
Assert.Equal("ciao", result);
}
[Fact]
public async Task ItRendersCodeBlockConsistingOfJustAValBlock2Async()
{
// Arrange
var context = new SKContext(new ContextVariables(), NullMemory.Instance, null, NullLogger.Instance);
var valBlock = new ValBlock("'arrivederci'");
// Act
var codeBlock = new CodeBlock(new List<Block> { valBlock }, "", NullLogger.Instance);
var result = await codeBlock.RenderCodeAsync(context);
// Assert
Assert.Equal("arrivederci", result);
}
[Fact]
public async Task ItInvokesFunctionCloningAllVariablesAsync()
{
// Arrange
const string FUNC = "funcName";
var variables = new ContextVariables { ["input"] = "zero", ["var1"] = "uno", ["var2"] = "due" };
var context = new SKContext(variables, NullMemory.Instance, this._skills.Object, NullLogger.Instance);
var funcId = new FunctionIdBlock(FUNC);
var canary0 = string.Empty;
var canary1 = string.Empty;
var canary2 = string.Empty;
var function = new Mock<ISKFunction>();
function
.Setup(x => x.InvokeAsync(It.IsAny<SKContext?>(), It.IsAny<CompleteRequestSettings?>(), It.IsAny<ILogger?>(), It.IsAny<CancellationToken?>()))
.Callback<SKContext?, CompleteRequestSettings?, ILogger?, CancellationToken?>((ctx, _, _, _) =>
{
canary0 = ctx!["input"];
canary1 = ctx["var1"];
canary2 = ctx["var2"];
ctx["input"] = "overridden";
ctx["var1"] = "overridden";
ctx["var2"] = "overridden";
});
this._skills.Setup(x => x.HasFunction(FUNC)).Returns(true);
this._skills.Setup(x => x.GetFunction(FUNC)).Returns(function.Object);
// Act
var codeBlock = new CodeBlock(new List<Block> { funcId }, "", NullLogger.Instance);
string result = await codeBlock.RenderCodeAsync(context);
// Assert - Values are received
Assert.Equal("zero", canary0);
Assert.Equal("uno", canary1);
Assert.Equal("due", canary2);
// Assert - Original context is intact
Assert.Equal("zero", variables["input"]);
Assert.Equal("uno", variables["var1"]);
Assert.Equal("due", variables["var2"]);
}
[Fact]
public async Task ItInvokesFunctionWithCustomVariableAsync()
{
// Arrange
const string FUNC = "funcName";
const string VAR = "varName";
const string VAR_VALUE = "varValue";
var variables = new ContextVariables { [VAR] = VAR_VALUE };
var context = new SKContext(variables, NullMemory.Instance, this._skills.Object, NullLogger.Instance);
var funcId = new FunctionIdBlock(FUNC);
var varBlock = new VarBlock($"${VAR}");
var canary = string.Empty;
var function = new Mock<ISKFunction>();
function
.Setup(x => x.InvokeAsync(It.IsAny<SKContext?>(), It.IsAny<CompleteRequestSettings?>(), It.IsAny<ILogger?>(), It.IsAny<CancellationToken?>()))
.Callback<SKContext?, CompleteRequestSettings?, ILogger?, CancellationToken?>((ctx, _, _, _) =>
{
canary = ctx!["input"];
});
this._skills.Setup(x => x.HasFunction(FUNC)).Returns(true);
this._skills.Setup(x => x.GetFunction(FUNC)).Returns(function.Object);
// Act
var codeBlock = new CodeBlock(new List<Block> { funcId, varBlock }, "", NullLogger.Instance);
string result = await codeBlock.RenderCodeAsync(context);
// Assert
Assert.Equal(VAR_VALUE, result);
Assert.Equal(VAR_VALUE, canary);
}
[Fact]
public async Task ItInvokesFunctionWithCustomValueAsync()
{
// Arrange
const string FUNC = "funcName";
const string VALUE = "value";
var context = new SKContext(new ContextVariables(), NullMemory.Instance, this._skills.Object, NullLogger.Instance);
var funcId = new FunctionIdBlock(FUNC);
var valBlock = new ValBlock($"'{VALUE}'");
var canary = string.Empty;
var function = new Mock<ISKFunction>();
function
.Setup(x => x.InvokeAsync(It.IsAny<SKContext?>(), It.IsAny<CompleteRequestSettings?>(), It.IsAny<ILogger?>(), It.IsAny<CancellationToken?>()))
.Callback<SKContext?, CompleteRequestSettings?, ILogger?, CancellationToken?>((ctx, _, _, _) =>
{
canary = ctx!["input"];
});
this._skills.Setup(x => x.HasFunction(FUNC)).Returns(true);
this._skills.Setup(x => x.GetFunction(FUNC)).Returns(function.Object);
// Act
var codeBlock = new CodeBlock(new List<Block> { funcId, valBlock }, "", NullLogger.Instance);
string result = await codeBlock.RenderCodeAsync(context);
// Assert
Assert.Equal(VALUE, result);
Assert.Equal(VALUE, canary);
}
}
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.SemanticKernel.TemplateEngine;
using Microsoft.SemanticKernel.TemplateEngine.Blocks;
using Xunit;
namespace SemanticKernel.UnitTests.TemplateEngine.Blocks;
public class FunctionIdBlockTests
{
[Fact]
public void ItHasTheCorrectType()
{
// Act
var target = new FunctionIdBlock("", NullLogger.Instance);
// Assert
Assert.Equal(BlockTypes.FunctionId, target.Type);
}
[Fact]
public void ItTrimsSpaces()
{
// Act + Assert
Assert.Equal("aa", new FunctionIdBlock(" aa ", NullLogger.Instance).Content);
}
[Theory]
[InlineData("0", true)]
[InlineData("1", true)]
[InlineData("a", true)]
[InlineData("_", true)]
[InlineData("01", true)]
[InlineData("01a", true)]
[InlineData("a01", true)]
[InlineData("_0", true)]
[InlineData("a01_", true)]
[InlineData("_a01", true)]
[InlineData(".", true)]
[InlineData("a.b", true)]
[InlineData("-", false)]
[InlineData("a b", false)]
[InlineData("a\nb", false)]
[InlineData("a\tb", false)]
[InlineData("a\rb", false)]
[InlineData("a,b", false)]
[InlineData("a-b", false)]
[InlineData("a+b", false)]
[InlineData("a~b", false)]
[InlineData("a`b", false)]
[InlineData("a!b", false)]
[InlineData("a@b", false)]
[InlineData("a#b", false)]
[InlineData("a$b", false)]
[InlineData("a%b", false)]
[InlineData("a^b", false)]
[InlineData("a*b", false)]
[InlineData("a(b", false)]
[InlineData("a)b", false)]
[InlineData("a|b", false)]
[InlineData("a{b", false)]
[InlineData("a}b", false)]
[InlineData("a[b", false)]
[InlineData("a]b", false)]
[InlineData("a:b", false)]
[InlineData("a;b", false)]
[InlineData("a'b", false)]
[InlineData("a\"b", false)]
[InlineData("a<b", false)]
[InlineData("a>b", false)]
[InlineData("a/b", false)]
[InlineData("a\\b", false)]
public void ItAllowsUnderscoreDotsLettersAndDigits(string name, bool isValid)
{
// Arrange
var target = new FunctionIdBlock($" {name} ");
// Act + Assert
Assert.Equal(isValid, target.IsValid(out _));
}
[Fact]
public void ItAllowsOnlyOneDot()
{
// Arrange
var target1 = new FunctionIdBlock("functionName");
var target2 = new FunctionIdBlock("skillName.functionName");
Assert.Throws<TemplateException>(() => new FunctionIdBlock("foo.skillName.functionName"));
// Act + Assert
Assert.True(target1.IsValid(out _));
Assert.True(target2.IsValid(out _));
}
}
@@ -0,0 +1,54 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel.TemplateEngine.Blocks;
using Xunit;
namespace SemanticKernel.UnitTests.TemplateEngine.Blocks;
public class TextBlockTests
{
[Fact]
public void ItHasTheCorrectType()
{
// Act
var target = new TextBlock("");
// Assert
Assert.Equal(BlockTypes.Text, target.Type);
}
[Fact]
public void ItPreservesEmptyValues()
{
// Act + Assert
Assert.Equal("", new TextBlock(null).Content);
Assert.Equal("", new TextBlock("").Content);
Assert.Equal(" ", new TextBlock(" ").Content);
Assert.Equal(" ", new TextBlock(" ").Content);
Assert.Equal(" \n", new TextBlock(" \n").Content);
}
[Fact]
public void ItIsAlwaysValid()
{
// Act + Assert
Assert.True(new TextBlock(null).IsValid(out _));
Assert.True(new TextBlock("").IsValid(out _));
Assert.True(new TextBlock(" ").IsValid(out _));
Assert.True(new TextBlock(" ").IsValid(out _));
Assert.True(new TextBlock(" \n").IsValid(out _));
Assert.True(new TextBlock(" \nabc").IsValid(out _));
}
[Fact]
public void ItRendersTheContentAsIs()
{
// Act + Assert
Assert.Equal("", new TextBlock(null).Render(null));
Assert.Equal("", new TextBlock("").Render(null));
Assert.Equal(" ", new TextBlock(" ").Render(null));
Assert.Equal(" ", new TextBlock(" ").Render(null));
Assert.Equal(" \n", new TextBlock(" \n").Render(null));
Assert.Equal("'x'", new TextBlock("'x'").Render(null));
}
}
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel.TemplateEngine.Blocks;
using Xunit;
namespace SemanticKernel.UnitTests.TemplateEngine.Blocks;
public class ValBlockTests
{
[Fact]
public void ItHasTheCorrectType()
{
// Act
var target = new ValBlock("");
// Assert
Assert.Equal(BlockTypes.Value, target.Type);
}
[Fact]
public void ItTrimsSpaces()
{
// Act + Assert
Assert.Equal("' '", new ValBlock(" ' ' ").Content);
Assert.Equal("\" \"", new ValBlock(" \" \" ").Content);
}
[Fact]
public void ItChecksIfAValueStartsWithQuote()
{
// Assert
Assert.True(ValBlock.HasValPrefix("'"));
Assert.True(ValBlock.HasValPrefix("'a"));
Assert.True(ValBlock.HasValPrefix("\""));
Assert.True(ValBlock.HasValPrefix("\"b"));
Assert.False(ValBlock.HasValPrefix("d'"));
Assert.False(ValBlock.HasValPrefix("e\""));
Assert.False(ValBlock.HasValPrefix(null));
Assert.False(ValBlock.HasValPrefix(""));
Assert.False(ValBlock.HasValPrefix("v"));
Assert.False(ValBlock.HasValPrefix("-"));
}
[Fact]
public void ItRequiresConsistentQuotes()
{
// Arrange
var validBlock1 = new ValBlock("'ciao'");
var validBlock2 = new ValBlock("\"hello\"");
var badBlock1 = new ValBlock("'nope\"");
var badBlock2 = new ValBlock("'no\"");
// Act + Assert
Assert.True(validBlock1.IsValid(out _));
Assert.True(validBlock2.IsValid(out _));
Assert.False(badBlock1.IsValid(out _));
Assert.False(badBlock2.IsValid(out _));
}
}
@@ -0,0 +1,161 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.TemplateEngine;
using Microsoft.SemanticKernel.TemplateEngine.Blocks;
using Xunit;
namespace SemanticKernel.UnitTests.TemplateEngine.Blocks;
public class VarBlockTests
{
[Fact]
public void ItHasTheCorrectType()
{
// Act
var target = new VarBlock("");
// Assert
Assert.Equal(BlockTypes.Variable, target.Type);
}
[Fact]
public void ItTrimsSpaces()
{
// Act + Assert
Assert.Equal("$", new VarBlock(" $ ").Content);
}
[Fact]
public void ItIgnoresSpacesAround()
{
// Act
var target = new VarBlock(" $var \n ");
// Assert
Assert.Equal("$var", target.Content);
}
[Fact]
public void ItRendersToEmptyStringWithoutVariables()
{
// Arrange
var target = new VarBlock(" $var \n ");
// Act
var result = target.Render(null);
// Assert
Assert.Equal(string.Empty, result);
}
[Fact]
public void ItRendersToEmptyStringIfVariableIsMissing()
{
// Arrange
var target = new VarBlock(" $var \n ");
var variables = new ContextVariables
{
["foo"] = "bar"
};
// Act
var result = target.Render(variables);
// Assert
Assert.Equal(string.Empty, result);
}
[Fact]
public void ItRendersToVariableValueWhenAvailable()
{
// Arrange
var target = new VarBlock(" $var \n ");
var variables = new ContextVariables
{
["foo"] = "bar",
["var"] = "able",
};
// Act
var result = target.Render(variables);
// Assert
Assert.Equal("able", result);
}
[Fact]
public void ItThrowsIfTheVarNameIsEmpty()
{
// Arrange
var variables = new ContextVariables
{
["foo"] = "bar",
["var"] = "able",
};
var target = new VarBlock(" $ ");
// Act + Assert
var ex = Assert.Throws<TemplateException>(() => target.Render(variables));
Assert.Equal(TemplateException.ErrorCodes.SyntaxError, ex.ErrorCode);
}
[Theory]
[InlineData("0", true)]
[InlineData("1", true)]
[InlineData("a", true)]
[InlineData("_", true)]
[InlineData("01", true)]
[InlineData("01a", true)]
[InlineData("a01", true)]
[InlineData("_0", true)]
[InlineData("a01_", true)]
[InlineData("_a01", true)]
[InlineData(".", false)]
[InlineData("-", false)]
[InlineData("a b", false)]
[InlineData("a\nb", false)]
[InlineData("a\tb", false)]
[InlineData("a\rb", false)]
[InlineData("a.b", false)]
[InlineData("a,b", false)]
[InlineData("a-b", false)]
[InlineData("a+b", false)]
[InlineData("a~b", false)]
[InlineData("a`b", false)]
[InlineData("a!b", false)]
[InlineData("a@b", false)]
[InlineData("a#b", false)]
[InlineData("a$b", false)]
[InlineData("a%b", false)]
[InlineData("a^b", false)]
[InlineData("a*b", false)]
[InlineData("a(b", false)]
[InlineData("a)b", false)]
[InlineData("a|b", false)]
[InlineData("a{b", false)]
[InlineData("a}b", false)]
[InlineData("a[b", false)]
[InlineData("a]b", false)]
[InlineData("a:b", false)]
[InlineData("a;b", false)]
[InlineData("a'b", false)]
[InlineData("a\"b", false)]
[InlineData("a<b", false)]
[InlineData("a>b", false)]
[InlineData("a/b", false)]
[InlineData("a\\b", false)]
public void ItAllowsUnderscoreLettersAndDigits(string name, bool isValid)
{
// Arrange
var target = new VarBlock($" ${name} ");
var variables = new ContextVariables { [name] = "value" };
// Act
var result = target.Render(variables);
// Assert
Assert.Equal(isValid, target.IsValid(out _));
if (isValid) { Assert.Equal("value", result); }
}
}
@@ -0,0 +1,143 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel.TemplateEngine;
using Microsoft.SemanticKernel.TemplateEngine.Blocks;
using Xunit;
namespace SemanticKernel.UnitTests.TemplateEngine;
public class CodeTokenizerTests
{
private readonly CodeTokenizer _target;
public CodeTokenizerTests()
{
this._target = new CodeTokenizer();
}
[Fact]
public void ItParsesEmptyText()
{
// Act + Assert
Assert.Empty(this._target.Tokenize(null));
Assert.Empty(this._target.Tokenize(""));
Assert.Empty(this._target.Tokenize(" "));
Assert.Empty(this._target.Tokenize(" \n "));
}
[Theory]
[InlineData("$", "$")]
[InlineData(" $ ", "$")]
[InlineData("$foo", "$foo")]
[InlineData("$foo ", "$foo")]
[InlineData(" $foo", "$foo")]
[InlineData(" $bar ", "$bar")]
public void ItParsesVarBlocks(string template, string content)
{
// Act
var blocks = this._target.Tokenize(template);
// Assert
Assert.Single(blocks);
Assert.Equal(content, blocks[0].Content);
Assert.Equal(BlockTypes.Variable, blocks[0].Type);
}
[Theory]
[InlineData("'", "'")]
[InlineData(" \" ", "\"")]
[InlineData("'foo'", "'foo'")]
[InlineData("'foo' ", "'foo'")]
[InlineData(" 'foo'", "'foo'")]
[InlineData(" \"bar\" ", "\"bar\"")]
public void ItParsesValBlocks(string template, string content)
{
// Act
var blocks = this._target.Tokenize(template);
// Assert
Assert.Single(blocks);
Assert.Equal(content, blocks[0].Content);
Assert.Equal(BlockTypes.Value, blocks[0].Type);
}
[Theory]
[InlineData("f", "f")]
[InlineData(" x ", "x")]
[InlineData("foo", "foo")]
[InlineData("fo.o ", "fo.o")]
[InlineData(" f.oo", "f.oo")]
[InlineData(" bar ", "bar")]
public void ItParsesFunctionIdBlocks(string template, string content)
{
// Act
var blocks = this._target.Tokenize(template);
// Assert
Assert.Single(blocks);
Assert.Equal(content, blocks[0].Content);
Assert.Equal(BlockTypes.FunctionId, blocks[0].Type);
}
[Fact]
public void ItParsesFunctionCalls()
{
// Arrange
var template1 = "x.y $foo";
var template2 = "xy $foo";
var template3 = "xy '$value'";
// Act
var blocks1 = this._target.Tokenize(template1);
var blocks2 = this._target.Tokenize(template2);
var blocks3 = this._target.Tokenize(template3);
// Assert
Assert.Equal(2, blocks1.Count);
Assert.Equal(2, blocks2.Count);
Assert.Equal(2, blocks3.Count);
Assert.Equal("x.y", blocks1[0].Content);
Assert.Equal("xy", blocks2[0].Content);
Assert.Equal("xy", blocks3[0].Content);
Assert.Equal(BlockTypes.FunctionId, blocks1[0].Type);
Assert.Equal(BlockTypes.FunctionId, blocks2[0].Type);
Assert.Equal(BlockTypes.FunctionId, blocks3[0].Type);
Assert.Equal("$foo", blocks1[1].Content);
Assert.Equal("$foo", blocks2[1].Content);
Assert.Equal("'$value'", blocks3[1].Content);
Assert.Equal(BlockTypes.Variable, blocks1[1].Type);
Assert.Equal(BlockTypes.Variable, blocks2[1].Type);
Assert.Equal(BlockTypes.Value, blocks3[1].Type);
}
[Fact]
public void ItSupportsEscaping()
{
// Arrange
var template = "func 'f\\'oo'";
// Act
var blocks = this._target.Tokenize(template);
// Assert
Assert.Equal(2, blocks.Count);
Assert.Equal("func", blocks[0].Content);
Assert.Equal("'f\'oo'", blocks[1].Content);
}
[Fact]
public void ItThrowsWhenSeparatorsAreMissing()
{
// Arrange
var template1 = @"call 'f\\'xy'";
var template2 = @"call 'f\\'x";
// Act
Assert.Throws<TemplateException>(() => this._target.Tokenize(template1));
Assert.Throws<TemplateException>(() => this._target.Tokenize(template2));
}
}
@@ -3,7 +3,6 @@
using System;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel.Memory;
using Microsoft.SemanticKernel.Orchestration;
using Microsoft.SemanticKernel.SkillDefinition;
@@ -12,134 +11,28 @@ using Microsoft.SemanticKernel.TemplateEngine.Blocks;
using Moq;
using SemanticKernel.UnitTests.XunitHelpers;
using Xunit;
using Xunit.Abstractions;
namespace SemanticKernel.UnitTests.TemplateEngine;
#pragma warning disable VSTHRD103 // ok to use WriteLine synchronously
#pragma warning disable CA1849 // ok to use WriteLine synchronously
public sealed class PromptTemplateEngineTests
{
private readonly IPromptTemplateEngine _target;
private readonly ContextVariables _variables;
private readonly Mock<IReadOnlySkillCollection> _skills;
private readonly ILogger _logger;
private readonly ITestOutputHelper _logger;
public PromptTemplateEngineTests()
public PromptTemplateEngineTests(ITestOutputHelper testOutputHelper)
{
this._logger = ConsoleLogger.Log;
this._target = new PromptTemplateEngine(this._logger);
this._logger = testOutputHelper;
this._target = new PromptTemplateEngine(ConsoleLogger.Log);
this._variables = new ContextVariables(Guid.NewGuid().ToString("X"));
this._skills = new Mock<IReadOnlySkillCollection>();
}
[Fact]
public void ItTokenizesEdgeCases1()
{
// Arrange
var template = "}}{{{ {$a}}}} {{b}}x}}";
// Act
var blocks = this._target.ExtractBlocks(template, false);
// Assert
Assert.Equal(5, blocks.Count);
Assert.Equal("}}{", blocks[0].Content);
Assert.Equal(BlockTypes.Text, blocks[0].Type);
Assert.Equal("{$a", blocks[1].Content);
Assert.Equal(BlockTypes.Code, blocks[1].Type);
Assert.Equal("}} ", blocks[2].Content);
Assert.Equal(BlockTypes.Text, blocks[2].Type);
Assert.Equal("b", blocks[3].Content);
Assert.Equal(BlockTypes.Code, blocks[3].Type);
Assert.Equal("x}}", blocks[4].Content);
Assert.Equal(BlockTypes.Text, blocks[4].Type);
}
[Fact]
public void ItTokenizesEdgeCases2()
{
// Arrange
var template = "}}{{{{$a}}}} {{b}}$x}}";
// Act
var blocks = this._target.ExtractBlocks(template);
// Assert
Assert.Equal(5, blocks.Count);
Assert.Equal("}}{{", blocks[0].Content);
Assert.Equal(BlockTypes.Text, blocks[0].Type);
Assert.Equal("$a", blocks[1].Content);
Assert.Equal(BlockTypes.Variable, blocks[1].Type);
Assert.Equal("}} ", blocks[2].Content);
Assert.Equal(BlockTypes.Text, blocks[2].Type);
Assert.Equal("b", blocks[3].Content);
Assert.Equal(BlockTypes.Code, blocks[3].Type);
Assert.Equal("$x}}", blocks[4].Content);
Assert.Equal(BlockTypes.Text, blocks[4].Type);
}
[Fact]
public void ItTokenizesAClassicPrompt()
{
// Arrange
var template = "this is a {{ $prompt }} with {{$some}} variables " +
"and {{function $calls}} that {{ also $use $variables }}";
// Act
var blocks = this._target.ExtractBlocks(template, true);
// Assert
Assert.Equal(8, blocks.Count);
Assert.Equal("this is a ", blocks[0].Content);
Assert.Equal(BlockTypes.Text, blocks[0].Type);
Assert.Equal("$prompt", blocks[1].Content);
Assert.Equal(BlockTypes.Variable, blocks[1].Type);
Assert.Equal(" with ", blocks[2].Content);
Assert.Equal(BlockTypes.Text, blocks[2].Type);
Assert.Equal("$some", blocks[3].Content);
Assert.Equal(BlockTypes.Variable, blocks[3].Type);
Assert.Equal(" variables and ", blocks[4].Content);
Assert.Equal(BlockTypes.Text, blocks[4].Type);
Assert.Equal("function $calls", blocks[5].Content);
Assert.Equal(BlockTypes.Code, blocks[5].Type);
Assert.Equal(" that ", blocks[6].Content);
Assert.Equal(BlockTypes.Text, blocks[6].Type);
Assert.Equal("also $use $variables", blocks[7].Content);
Assert.Equal(BlockTypes.Code, blocks[7].Type);
}
[Theory]
[InlineData(null, 1)]
[InlineData("", 1)]
[InlineData("}}{{a}} {{b}}x", 5)]
[InlineData("}}{{ -a}} {{b}}x", 5)]
[InlineData("}}{{ -a\n}} {{b}}x", 5)]
[InlineData("}}{{ -a\n} } {{b}}x", 3)]
public void ItTokenizesTheRightTokenCount(string? template, int blockCount)
{
// Act
var blocks = this._target.ExtractBlocks(template, false);
// Assert
Assert.Equal(blockCount, blocks.Count);
}
[Fact]
public void ItRendersVariables()
{
@@ -230,24 +123,24 @@ public sealed class PromptTemplateEngineTests
}
[Fact]
public async Task ItRendersCodeUsingInputInstanceAsync()
public async Task ItRendersCodeUsingInputAsync()
{
// Arrange
[SKFunction("test")]
[SKFunctionName("test")]
string MyFunctionAsync(SKContext cx)
{
this._logger.LogTrace("MyFunction call received, input: {0}", cx.Variables.Input);
this._logger.WriteLine("MyFunction call received, input: {0}", cx.Variables.Input);
return $"F({cx.Variables.Input})";
}
var func = SKFunction.FromNativeMethod(Method(MyFunctionAsync), this);
ISKFunction? func = SKFunction.FromNativeMethod(Method(MyFunctionAsync), this);
Assert.NotNull(func);
this._variables.Update("INPUT-BAR");
var template = "foo-{{function}}-baz";
this._skills.Setup(x => x.HasNativeFunction("function")).Returns(true);
this._skills.Setup(x => x.GetNativeFunction("function")).Returns(func);
this._skills.Setup(x => x.HasFunction("function")).Returns(true);
this._skills.Setup(x => x.GetFunction("function")).Returns(func);
var context = this.MockContext();
// Act
@@ -258,80 +151,24 @@ public sealed class PromptTemplateEngineTests
}
[Fact]
public async Task ItRendersCodeUsingInputStaticAsync()
{
// Arrange
[SKFunction("test")]
[SKFunctionName("test")]
static string MyFunctionAsync(SKContext cx)
{
ConsoleLogger.Log.LogTrace("MyFunction call received, input: {0}", cx.Variables.Input);
return $"F({cx.Variables.Input})";
}
var func = SKFunction.FromNativeMethod(Method(MyFunctionAsync));
Assert.NotNull(func);
this._variables.Update("INPUT-BAR");
var template = "foo-{{function}}-baz";
this._skills.Setup(x => x.HasNativeFunction("function")).Returns(true);
this._skills.Setup(x => x.GetNativeFunction("function")).Returns(func);
var context = this.MockContext();
// Act
var result = await this._target.RenderAsync(template, context);
// Assert
Assert.Equal("foo-F(INPUT-BAR)-baz", result);
}
[Fact]
public async Task ItRendersCodeUsingVariablesInstanceAsync()
public async Task ItRendersCodeUsingVariablesAsync()
{
// Arrange
[SKFunction("test")]
[SKFunctionName("test")]
string MyFunctionAsync(SKContext cx)
{
this._logger.LogTrace("MyFunction call received, input: {0}", cx.Variables.Input);
this._logger.WriteLine("MyFunction call received, input: {0}", cx.Variables.Input);
return $"F({cx.Variables.Input})";
}
var func = SKFunction.FromNativeMethod(Method(MyFunctionAsync), this);
ISKFunction? func = SKFunction.FromNativeMethod(Method(MyFunctionAsync), this);
Assert.NotNull(func);
this._variables.Set("myVar", "BAR");
var template = "foo-{{function $myVar}}-baz";
this._skills.Setup(x => x.HasNativeFunction("function")).Returns(true);
this._skills.Setup(x => x.GetNativeFunction("function")).Returns(func);
var context = this.MockContext();
// Act
var result = await this._target.RenderAsync(template, context);
// Assert
Assert.Equal("foo-F(BAR)-baz", result);
}
[Fact]
public async Task ItRendersCodeUsingVariablesStaticAsync()
{
// Arrange
[SKFunction("test")]
[SKFunctionName("test")]
static string MyFunctionAsync(SKContext cx)
{
ConsoleLogger.Log.LogTrace("MyFunction call received, input: {0}", cx.Variables.Input);
return $"F({cx.Variables.Input})";
}
var func = SKFunction.FromNativeMethod(Method(MyFunctionAsync));
Assert.NotNull(func);
this._variables.Set("myVar", "BAR");
var template = "foo-{{function $myVar}}-baz";
this._skills.Setup(x => x.HasNativeFunction("function")).Returns(true);
this._skills.Setup(x => x.GetNativeFunction("function")).Returns(func);
this._skills.Setup(x => x.HasFunction("function")).Returns(true);
this._skills.Setup(x => x.GetFunction("function")).Returns(func);
var context = this.MockContext();
// Act
@@ -350,17 +187,18 @@ public sealed class PromptTemplateEngineTests
Task<string> MyFunctionAsync(SKContext cx)
{
// Input value should be "BAR" because the variable $myVar is passed in
this._logger.LogTrace("MyFunction call received, input: {0}", cx.Variables.Input);
this._logger.WriteLine("MyFunction call received, input: {0}", cx.Variables.Input);
return Task.FromResult(cx.Variables.Input);
}
var func = SKFunction.FromNativeMethod(Method(MyFunctionAsync), this);
ISKFunction? func = SKFunction.FromNativeMethod(Method(MyFunctionAsync), this);
Assert.NotNull(func);
this._variables.Set("myVar", "BAR");
var template = "foo-{{function $myVar}}-baz";
this._skills.Setup(x => x.HasNativeFunction("function")).Returns(true);
this._skills.Setup(x => x.GetNativeFunction("function")).Returns(func);
this._skills.Setup(x => x.HasFunction("function")).Returns(true);
this._skills.Setup(x => x.GetFunction("function")).Returns(func);
var context = this.MockContext();
// Act
@@ -381,6 +219,8 @@ public sealed class PromptTemplateEngineTests
this._variables,
NullMemory.Instance,
this._skills.Object,
this._logger);
ConsoleLogger.Log);
}
}
#pragma warning restore VSTHRD103
#pragma warning restore CA1849
@@ -0,0 +1,227 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel.TemplateEngine;
using Microsoft.SemanticKernel.TemplateEngine.Blocks;
using Xunit;
namespace SemanticKernel.UnitTests.TemplateEngine;
public class TemplateTokenizerTests
{
private readonly TemplateTokenizer _target;
public TemplateTokenizerTests()
{
this._target = new TemplateTokenizer();
}
[Theory]
[InlineData(null, BlockTypes.Text)]
[InlineData("", BlockTypes.Text)]
[InlineData(" ", BlockTypes.Text)]
[InlineData(" ", BlockTypes.Text)]
[InlineData(" {} ", BlockTypes.Text)]
[InlineData(" {{} ", BlockTypes.Text)]
[InlineData(" {{ } } } ", BlockTypes.Text)]
[InlineData(" { { }} }", BlockTypes.Text)]
[InlineData("{{}}", BlockTypes.Text)]
[InlineData("{{ }}", BlockTypes.Text)]
[InlineData("{{ }}", BlockTypes.Text)]
[InlineData("{{ '}}x", BlockTypes.Text)]
[InlineData("{{ \"}}x", BlockTypes.Text)]
internal void ItParsesTextWithoutCode(string? text, BlockTypes type)
{
// Act
var blocks = this._target.Tokenize(text);
// Assert
Assert.Equal(1, blocks.Count);
Assert.Equal(type, blocks[0].Type);
}
[Theory]
[InlineData("", BlockTypes.Text)]
[InlineData(" ", BlockTypes.Text)]
[InlineData(" ", BlockTypes.Text)]
[InlineData(" aaa ", BlockTypes.Text)]
[InlineData("{{$}}", BlockTypes.Variable)]
[InlineData("{{$a}}", BlockTypes.Variable)]
[InlineData("{{ $a}}", BlockTypes.Variable)]
[InlineData("{{ $a }}", BlockTypes.Variable)]
[InlineData("{{ $a }}", BlockTypes.Variable)]
[InlineData("{{code}}", BlockTypes.Code)]
[InlineData("{{code }}", BlockTypes.Code)]
[InlineData("{{ code }}", BlockTypes.Code)]
[InlineData("{{ code }}", BlockTypes.Code)]
[InlineData("{{ code }}", BlockTypes.Code)]
[InlineData("{{''}}", BlockTypes.Value)]
[InlineData("{{' '}}", BlockTypes.Value)]
[InlineData("{{ ' '}}", BlockTypes.Value)]
[InlineData("{{ ' ' }}", BlockTypes.Value)]
[InlineData("{{ ' ' }}", BlockTypes.Value)]
[InlineData("{{ ' ' }}", BlockTypes.Value)]
internal void ItParsesBasicBlocks(string? text, BlockTypes type)
{
// Act
var blocks = this._target.Tokenize(text);
// Assert
Assert.Equal(1, blocks.Count);
Assert.Equal(type, blocks[0].Type);
}
[Theory]
[InlineData(null, 1)]
[InlineData("", 1)]
[InlineData("}}{{a}} {{b}}x", 5)]
[InlineData("}}{{ -a}} {{b}}x", 5)]
[InlineData("}}{{ -a\n}} {{b}}x", 5)]
[InlineData("}}{{ -a\n} } {{b}}x", 3)]
public void ItTokenizesTheRightTokenCount(string? template, int blockCount)
{
// Act
var blocks = this._target.Tokenize(template);
// Assert
Assert.Equal(blockCount, blocks.Count);
}
[Fact]
public void ItTokenizesEdgeCasesCorrectly1()
{
// Act
var blocks1 = this._target.Tokenize("{{{{a}}");
var blocks2 = this._target.Tokenize("{{'{{a}}");
var blocks3 = this._target.Tokenize("{{'a}}");
var blocks4 = this._target.Tokenize("{{a'}}");
// Assert - Count
Assert.Equal(2, blocks1.Count);
Assert.Equal(1, blocks2.Count);
Assert.Equal(1, blocks3.Count);
Assert.Equal(1, blocks4.Count);
// Assert - Type
Assert.Equal(BlockTypes.Text, blocks1[0].Type);
Assert.Equal(BlockTypes.Code, blocks1[1].Type);
// Assert - Content
Assert.Equal("{{", blocks1[0].Content);
Assert.Equal("a", blocks1[1].Content);
}
[Fact]
public void ItTokenizesEdgeCasesCorrectly2()
{
// Arrange
var template = "}}{{{ {$a}}}} {{b}}x}}";
// Act
var blocks = this._target.Tokenize(template);
// Assert
Assert.Equal(5, blocks.Count);
Assert.Equal("}}{", blocks[0].Content);
Assert.Equal(BlockTypes.Text, blocks[0].Type);
Assert.Equal("{$a", blocks[1].Content);
Assert.Equal(BlockTypes.Code, blocks[1].Type);
Assert.Equal("}} ", blocks[2].Content);
Assert.Equal(BlockTypes.Text, blocks[2].Type);
Assert.Equal("b", blocks[3].Content);
Assert.Equal(BlockTypes.Code, blocks[3].Type);
Assert.Equal("x}}", blocks[4].Content);
Assert.Equal(BlockTypes.Text, blocks[4].Type);
}
[Fact]
public void ItTokenizesEdgeCasesCorrectly3()
{
// Arrange
var template = "}}{{{{$a}}}} {{b}}$x}}";
// Act
var blocks = this._target.Tokenize(template);
// Assert
Assert.Equal(5, blocks.Count);
Assert.Equal("}}{{", blocks[0].Content);
Assert.Equal(BlockTypes.Text, blocks[0].Type);
Assert.Equal("$a", blocks[1].Content);
Assert.Equal(BlockTypes.Variable, blocks[1].Type);
Assert.Equal("}} ", blocks[2].Content);
Assert.Equal(BlockTypes.Text, blocks[2].Type);
Assert.Equal("b", blocks[3].Content);
Assert.Equal(BlockTypes.Code, blocks[3].Type);
Assert.Equal("$x}}", blocks[4].Content);
Assert.Equal(BlockTypes.Text, blocks[4].Type);
}
[Theory]
[InlineData("{{a$}}")]
[InlineData("{{a$a}}")]
[InlineData("{{a''}}")]
[InlineData("{{a\"\"}}")]
[InlineData("{{a'b'}}")]
[InlineData("{{a\"b\"}}")]
[InlineData("{{a'b' }}")]
[InlineData("{{a\"b\" }}")]
[InlineData("{{ asis 'f\\'oo' }}")]
public void ItTokenizesEdgeCasesCorrectly4(string template)
{
// Act
var blocks = this._target.Tokenize(template);
// Assert
Assert.Equal(1, blocks.Count);
Assert.Equal(BlockTypes.Code, blocks[0].Type);
Assert.Equal(template[2..^2].Trim(), blocks[0].Content);
}
[Fact]
public void ItTokenizesATypicalPrompt()
{
// Arrange
var template = "this is a {{ $prompt }} with {{$some}} variables " +
"and {{function $calls}} {{ and 'values' }}";
// Act
var blocks = this._target.Tokenize(template);
// Assert
Assert.Equal(8, blocks.Count);
Assert.Equal("this is a ", blocks[0].Content);
Assert.Equal(BlockTypes.Text, blocks[0].Type);
Assert.Equal("$prompt", blocks[1].Content);
Assert.Equal(BlockTypes.Variable, blocks[1].Type);
Assert.Equal(" with ", blocks[2].Content);
Assert.Equal(BlockTypes.Text, blocks[2].Type);
Assert.Equal("$some", blocks[3].Content);
Assert.Equal(BlockTypes.Variable, blocks[3].Type);
Assert.Equal(" variables and ", blocks[4].Content);
Assert.Equal(BlockTypes.Text, blocks[4].Type);
Assert.Equal("function $calls", blocks[5].Content);
Assert.Equal(BlockTypes.Code, blocks[5].Type);
Assert.Equal(" ", blocks[6].Content);
Assert.Equal(BlockTypes.Text, blocks[6].Type);
Assert.Equal("and 'values'", blocks[7].Content);
Assert.Equal(BlockTypes.Code, blocks[7].Type);
}
}
@@ -32,7 +32,7 @@ internal class MinHeap<T> : IEnumerable<T> where T : IComparable<T>
this._items = new T[capacity + 1];
//
// The 0'th item is a sentinal entry that simplies the code
// The 0'th item is a sentinel entry that simplifies the code
//
this._items[0] = minValue;
}
@@ -53,7 +53,7 @@ internal class MinHeap<T> : IEnumerable<T> where T : IComparable<T>
}
}
public int Capacity => this._items.Length - 1; // 0'th item is always a sentinal to simplify code
public int Capacity => this._items.Length - 1; // 0'th item is always a sentinel to simplify code
public T this[int index]
{
@@ -87,7 +87,7 @@ internal class MinHeap<T> : IEnumerable<T> where T : IComparable<T>
public void Add(T item)
{
//
// the 0'th item is always a sentinal and not included in this._count.
// the 0'th item is always a sentinel and not included in this._count.
// The length of the buffer is always this._count + 1
//
this._count++;
@@ -116,7 +116,7 @@ internal class MinHeap<T> : IEnumerable<T> where T : IComparable<T>
for (int i = startAt; i < newItemCount; ++i)
{
//
// the 0'th item is always a sentinal and not included in this._count.
// the 0'th item is always a sentinel and not included in this._count.
// The length of the buffer is always this._count + 1
//
this._count++;
@@ -150,7 +150,7 @@ internal class MinHeap<T> : IEnumerable<T> where T : IComparable<T>
{
Verify.GreaterThan(capacity, MinCapacity, $"MinHeap capacity must be greater than {MinCapacity}.");
// 0th item is always a sentinal
// 0th item is always a sentinel
capacity++;
if (capacity > this._items.Length)
{
@@ -221,7 +221,7 @@ internal class MinHeap<T> : IEnumerable<T> where T : IComparable<T>
public virtual IEnumerator<T> GetEnumerator()
{
// The 0'th item in the queue is a sentinal. i is 1 based.
// The 0'th item in the queue is a sentinel. i is 1 based.
for (int i = 1; i <= this._count; ++i)
{
yield return this._items[i];
@@ -234,19 +234,19 @@ internal class MinHeap<T> : IEnumerable<T> where T : IComparable<T>
}
/// <summary>
/// Heap Sort in-place.
/// This is destructive. Once you do this, the heap order is lost.
/// Heap Sort in-place.
/// This is destructive. Once you do this, the heap order is lost.
/// The advantage on in-place is that we don't need to do another allocation
/// </summary>
public void SortDescending()
{
int count = this._count;
int i = count; // remember that the 0'th item in the queue is always a sentinal. So i is 1 based
int i = count; // remember that the 0'th item in the queue is always a sentinel. So i is 1 based
while (this._count > 0)
{
//
// this deques the item with the current LOWEST relevancy
// this dequeues the item with the current LOWEST relevancy
// We take that and place it at the 'back' of the array - thus inverting it
//
T item = this.RemoveTop();
@@ -8,7 +8,7 @@ namespace Microsoft.SemanticKernel.Memory.Collections;
/// <summary>
/// A collector for Top N matches. Keeps only the best N matches by Score.
/// Automatically flushes out any not in the top N.
/// By default, items are not sorted by score untily you call <see cref="TopNCollection{T}.SortByScore"/>.
/// By default, items are not sorted by score until you call <see cref="TopNCollection{T}.SortByScore"/>.
/// </summary>
internal class TopNCollection<T> : IEnumerable<ScoredValue<T>>
{
@@ -49,6 +49,28 @@ public interface IReadOnlySkillCollection
/// <returns>True if the function exists</returns>
bool HasNativeFunction(string functionName);
/// <summary>
/// Return the function delegate stored in the collection, regardless of the function type
/// </summary>
/// <param name="functionName">Function name</param>
/// <returns>Function delegate</returns>
ISKFunction GetFunction(string functionName);
/// <summary>
/// Return the function delegate stored in the collection, regardless of the function type
/// </summary>
/// <param name="functionName">Function name</param>
/// <param name="skillName">Skill name</param>
/// <returns>Function delegate</returns>
ISKFunction GetFunction(string skillName, string functionName);
/// <summary>
/// Return the semantic function delegate stored in the collection
/// </summary>
/// <param name="functionName">Function name</param>
/// <returns>Semantic function delegate</returns>
ISKFunction GetSemanticFunction(string functionName);
/// <summary>
/// Return the semantic function delegate stored in the collection
/// </summary>
@@ -46,6 +46,24 @@ internal class ReadOnlySkillCollection : IReadOnlySkillCollection
return this._skillCollection.HasNativeFunction(functionName);
}
/// <inheritdoc/>
public ISKFunction GetFunction(string functionName)
{
return this._skillCollection.GetFunction(functionName);
}
/// <inheritdoc/>
public ISKFunction GetFunction(string skillName, string functionName)
{
return this._skillCollection.GetFunction(skillName, functionName);
}
/// <inheritdoc/>
public ISKFunction GetSemanticFunction(string functionName)
{
return this._skillCollection.GetSemanticFunction(functionName);
}
/// <inheritdoc/>
public ISKFunction GetSemanticFunction(string skillName, string functionName)
{
@@ -96,6 +96,32 @@ public class SkillCollection : ISkillCollection
return this.HasNativeFunction(GlobalSkill, functionName);
}
/// <inheritdoc/>
public ISKFunction GetFunction(string functionName)
{
return this.GetFunction(GlobalSkill, functionName);
}
/// <inheritdoc/>
public ISKFunction GetFunction(string skillName, string functionName)
{
if (this.HasFunction(skillName, functionName))
{
return this._skillCollection[skillName][functionName];
}
this._log.LogError("Function not available: skill:{0} function:{1}", skillName, functionName);
throw new KernelException(
KernelException.ErrorCodes.FunctionNotAvailable,
$"Function not available {skillName}.{functionName}");
}
/// <inheritdoc/>
public ISKFunction GetSemanticFunction(string functionName)
{
return this.GetSemanticFunction(GlobalSkill, functionName);
}
/// <inheritdoc/>
public ISKFunction GetSemanticFunction(string skillName, string functionName)
{
@@ -1,10 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.SemanticKernel.Orchestration;
namespace Microsoft.SemanticKernel.TemplateEngine.Blocks;
@@ -15,7 +12,12 @@ public abstract class Block
{
internal virtual BlockTypes Type => BlockTypes.Undefined;
internal string Content { get; set; } = string.Empty;
// internal virtual bool? SynchronousRendering => null;
/// <summary>
/// The block content
/// </summary>
internal string Content { get; }
/// <summary>
/// App logger
@@ -25,18 +27,19 @@ public abstract class Block
/// <summary>
/// Base constructor
/// </summary>
/// <param name="content">Block content</param>
/// <param name="log">App logger</param>
protected Block(ILogger? log = null)
protected Block(string? content, ILogger? log = null)
{
if (log != null) { this.Log = log; }
this.Content = content ?? string.Empty;
}
internal virtual Task<string> RenderCodeAsync(SKContext executionContext)
{
throw new NotImplementedException("This block doesn't support code execution");
}
internal abstract bool IsValid(out string error);
internal abstract string Render(ContextVariables? variables);
/// <summary>
/// Check if the block content is valid.
/// </summary>
/// <param name="errorMsg">Error message in case the content is not valid</param>
/// <returns>True if the block content is valid</returns>
public abstract bool IsValid(out string errorMsg);
}
@@ -8,4 +8,6 @@ internal enum BlockTypes
Text = 1,
Code = 2,
Variable = 3,
Value = 4,
FunctionId = 5,
}
@@ -1,10 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel.Orchestration;
@@ -13,87 +10,66 @@ using Microsoft.SemanticKernel.SkillDefinition;
namespace Microsoft.SemanticKernel.TemplateEngine.Blocks;
internal class CodeBlock : Block
#pragma warning disable CA2254 // error strings are used also internally, not just for logging
// ReSharper disable TemplateIsNotCompileTimeConstantProblem
internal class CodeBlock : Block, ICodeRendering
{
internal override BlockTypes Type => BlockTypes.Code;
internal CodeBlock(string content, ILogger log) : base(log)
public CodeBlock(string? content, ILogger log)
: this(new CodeTokenizer(log).Tokenize(content), content?.Trim(), log)
{
this.Content = content;
}
#pragma warning disable CA2254 // error strings are used also internally, not just for logging
// ReSharper disable TemplateIsNotCompileTimeConstantProblem
internal override bool IsValid(out string error)
public CodeBlock(List<Block> tokens, string? content, ILogger log)
: base(content?.Trim(), log)
{
error = "";
this._tokens = tokens;
}
List<string> partsToValidate = this.Content.Split(' ', '\t', '\r', '\n')
.Where(x => !string.IsNullOrEmpty(x.Trim()))
.ToList();
public override bool IsValid(out string errorMsg)
{
errorMsg = "";
for (var index = 0; index < partsToValidate.Count; index++)
foreach (Block token in this._tokens)
{
var part = partsToValidate[index];
if (index == 0) // There is only a function name
if (!token.IsValid(out errorMsg))
{
if (VarBlock.HasVarPrefix(part))
{
error = $"Variables cannot be used as function names [`{part}`]";
this.Log.LogError(error);
return false;
}
if (!Regex.IsMatch(part, "^[a-zA-Z0-9_.]*$"))
{
error = $"The function name `{part}` contains invalid characters";
this.Log.LogError(error);
return false;
}
this.Log.LogError(errorMsg);
return false;
}
else // The function has parameters
}
if (this._tokens.Count > 1)
{
if (this._tokens[0].Type != BlockTypes.FunctionId)
{
if (!VarBlock.HasVarPrefix(part))
{
error = $"`{part}` is not a valid function parameter: parameters must be variables.";
this.Log.LogError(error);
return false;
}
if (part.Length < 2)
{
error = $"`{part}` is not a valid variable.";
this.Log.LogError(error);
return false;
}
if (!VarBlock.IsValidVarName(part.Substring(1)))
{
error = $"`{part}` variable name is not valid.";
this.Log.LogError(error);
return false;
}
errorMsg = $"Unexpected second token found: {this._tokens[1].Content}";
this.Log.LogError(errorMsg);
return false;
}
if (this._tokens[1].Type != BlockTypes.Value && this._tokens[1].Type != BlockTypes.Variable)
{
errorMsg = "Functions support only one parameter";
this.Log.LogError(errorMsg);
return false;
}
}
if (this._tokens.Count > 2)
{
errorMsg = $"Unexpected second token found: {this._tokens[1].Content}";
this.Log.LogError(errorMsg);
return false;
}
this._validated = true;
return true;
}
// ReSharper restore TemplateIsNotCompileTimeConstantProblem
#pragma warning restore CA2254
internal override string Render(ContextVariables? variables)
{
throw new InvalidOperationException(
"Code blocks rendering requires IReadOnlySkillCollection. Incorrect method call.");
}
#pragma warning disable CA2254 // error strings are used also internally, not just for logging
// ReSharper disable TemplateIsNotCompileTimeConstantProblem
internal override async Task<string> RenderCodeAsync(SKContext context)
public async Task<string> RenderCodeAsync(SKContext context)
{
if (!this._validated && !this.IsValid(out var error))
{
@@ -102,33 +78,48 @@ internal class CodeBlock : Block
this.Log.LogTrace("Rendering code: `{0}`", this.Content);
List<string> parts = this.Content.Split(' ', '\t', '\r', '\n')
.Where(x => !string.IsNullOrEmpty(x.Trim()))
.ToList();
var functionName = parts[0];
context.ThrowIfSkillCollectionNotSet();
if (!this.GetFunctionFromSkillCollection(context.Skills!, functionName, out ISKFunction? function))
switch (this._tokens[0].Type)
{
var errorMsg = $"Function not found `{functionName}`";
case BlockTypes.Value:
case BlockTypes.Variable:
return ((ITextRendering)this._tokens[0]).Render(context.Variables);
case BlockTypes.FunctionId:
return await this.RenderFunctionCallAsync((FunctionIdBlock)this._tokens[0], context);
}
throw new TemplateException(TemplateException.ErrorCodes.UnexpectedBlockType,
$"Unexpected first token type: {this._tokens[0].Type:G}");
}
#region private ================================================================================
private bool _validated;
private readonly List<Block> _tokens;
private async Task<string> RenderFunctionCallAsync(FunctionIdBlock fBlock, SKContext context)
{
context.ThrowIfSkillCollectionNotSet();
if (!this.GetFunctionFromSkillCollection(context.Skills!, fBlock, out ISKFunction? function))
{
var errorMsg = $"Function `{fBlock.Content}` not found";
this.Log.LogError(errorMsg);
throw new TemplateException(TemplateException.ErrorCodes.FunctionNotFound, errorMsg);
}
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
// Using $input by default, e.g. when the syntax is {{functionName}}
// TODO: unit test, verify that all context variables are passed to Render()
ContextVariables variablesClone = context.Variables.Clone();
if (parts.Count > 1)
// If the code syntax is {{functionName $varName}} use $varName instead of $input
// If the code syntax is {{functionName 'value'}} use "value" instead of $input
if (this._tokens.Count > 1)
{
this.Log.LogTrace("Passing required variable: `{0}`", parts[1]);
// If the code syntax is {{functionName $varName}} use $varName instead of $input
string value = new VarBlock(parts[1], this.Log).Render(variablesClone);
variablesClone.Update(value);
// TODO: PII
this.Log.LogTrace("Passing variable/value: `{0}`", this._tokens[1].Content);
string input = ((ITextRendering)this._tokens[1]).Render(variablesClone);
variablesClone.Update(input);
}
var result = await function.InvokeWithCustomInputAsync(
SKContext result = await function.InvokeWithCustomInputAsync(
variablesClone,
context.Memory,
context.Skills,
@@ -137,56 +128,31 @@ internal class CodeBlock : Block
if (result.ErrorOccurred)
{
var errorMsg = $"Function `{functionName}` execution failed. {result.LastException?.GetType().FullName}: {result.LastErrorDescription}";
var errorMsg = $"Function `{fBlock.Content}` execution failed. {result.LastException?.GetType().FullName}: {result.LastErrorDescription}";
this.Log.LogError(errorMsg);
throw new TemplateException(TemplateException.ErrorCodes.RuntimeError, errorMsg, result.LastException);
}
return result.Result;
}
// ReSharper restore TemplateIsNotCompileTimeConstantProblem
#pragma warning restore CA2254
#region private ================================================================================
private bool _validated;
private bool GetFunctionFromSkillCollection(IReadOnlySkillCollection skills, string functionName,
private bool GetFunctionFromSkillCollection(
IReadOnlySkillCollection skills,
FunctionIdBlock fBlock,
[NotNullWhen(true)] out ISKFunction? function)
{
// Search in the global space (only native functions there)
if (skills.HasNativeFunction(functionName))
// Function in the global skill
if (string.IsNullOrEmpty(fBlock.SkillName) && skills.HasFunction(fBlock.FunctionName))
{
function = skills.GetNativeFunction(functionName);
function = skills.GetFunction(fBlock.FunctionName);
return true;
}
// If the function contains a skill name...
if (functionName.Contains('.', StringComparison.InvariantCulture))
// Function within a specific skill
if (!string.IsNullOrEmpty(fBlock.SkillName) && skills.HasFunction(fBlock.SkillName, fBlock.FunctionName))
{
var functionNameParts = functionName.Split('.');
if (functionNameParts.Length > 2)
{
this.Log.LogError("Invalid function name `{0}`", functionName);
throw new ArgumentOutOfRangeException(
$"Invalid function name `{functionName}`. " +
"A Function name can contain only one `.` to separate skill name from function name.");
}
var skillName = functionNameParts[0];
functionName = functionNameParts[1];
if (skills.HasNativeFunction(skillName, functionName))
{
function = skills.GetNativeFunction(skillName, functionName);
return true;
}
if (skills.HasSemanticFunction(skillName, functionName))
{
function = skills.GetSemanticFunction(skillName, functionName);
return true;
}
function = skills.GetFunction(fBlock.SkillName, fBlock.FunctionName);
return true;
}
function = null;
@@ -195,3 +161,5 @@ internal class CodeBlock : Block
#endregion
}
// ReSharper restore TemplateIsNotCompileTimeConstantProblem
#pragma warning restore CA2254
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel.Orchestration;
namespace Microsoft.SemanticKernel.TemplateEngine.Blocks;
internal class FunctionIdBlock : Block, ITextRendering
{
internal override BlockTypes Type => BlockTypes.FunctionId;
internal string SkillName { get; } = string.Empty;
internal string FunctionName { get; } = string.Empty;
public FunctionIdBlock(string? text, ILogger? log = null)
: base(text?.Trim(), log)
{
var functionNameParts = this.Content.Split('.');
if (functionNameParts.Length > 2)
{
this.Log.LogError("Invalid function name `{0}`", this.Content);
throw new TemplateException(TemplateException.ErrorCodes.SyntaxError,
"A function name can contain at most one dot separating the skill name from the function name");
}
if (functionNameParts.Length == 2)
{
this.SkillName = functionNameParts[0];
this.FunctionName = functionNameParts[1];
return;
}
this.FunctionName = this.Content;
}
public override bool IsValid(out string errorMsg)
{
if (!Regex.IsMatch(this.Content, "^[a-zA-Z0-9_.]*$"))
{
errorMsg = "The function identifier is empty";
return false;
}
if (HasMoreThanOneDot(this.Content))
{
errorMsg = "The function identifier can contain max one '.' char separating skill name from function name";
return false;
}
errorMsg = "";
return true;
}
public string Render(ContextVariables? variables)
{
return this.Content;
}
private static bool HasMoreThanOneDot(string? value)
{
if (value == null || value.Length < 2) { return false; }
int count = 0;
return value.Any(t => t == '.' && ++count > 1);
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.SemanticKernel.Orchestration;
namespace Microsoft.SemanticKernel.TemplateEngine.Blocks;
/// <summary>
/// Interface of dynamic blocks that need async IO to be rendered.
/// </summary>
public interface ICodeRendering
{
/// <summary>
/// Render the block using the given context, potentially using external I/O.
/// </summary>
/// <param name="context">SK execution context</param>
/// <returns>Rendered content</returns>
internal Task<string> RenderCodeAsync(SKContext context);
}
@@ -0,0 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.SemanticKernel.Orchestration;
namespace Microsoft.SemanticKernel.TemplateEngine.Blocks;
/// <summary>
/// Interface of static blocks that don't need async IO to be rendered.
/// </summary>
public interface ITextRendering
{
/// <summary>
/// Render the block using only the given variables.
/// </summary>
/// <param name="variables">Optional variables used to render the block</param>
/// <returns>Rendered content</returns>
internal string Render(ContextVariables? variables);
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.SemanticKernel.TemplateEngine.Blocks;
internal static class Symbols
{
internal const char BlockStarter = '{';
internal const char BlockEnder = '}';
internal const char VarPrefix = '$';
internal const char DblQuote = '"';
internal const char SglQuote = '\'';
internal const char EscapeChar = '\\';
internal const char Space = ' ';
internal const char Tab = '\t';
internal const char NewLine = '\n';
internal const char CarriageReturn = '\r';
}
@@ -5,29 +5,27 @@ using Microsoft.SemanticKernel.Orchestration;
namespace Microsoft.SemanticKernel.TemplateEngine.Blocks;
internal class TextBlock : Block
internal class TextBlock : Block, ITextRendering
{
internal override BlockTypes Type => BlockTypes.Text;
internal TextBlock(string content, ILogger? log = null)
: base(log)
public TextBlock(string? text, ILogger? log = null)
: base(text, log)
{
this.Content = content;
}
internal TextBlock(string text, int startIndex, int stopIndex, ILogger log)
: base(log)
public TextBlock(string text, int startIndex, int stopIndex, ILogger log)
: base(text.Substring(startIndex, stopIndex - startIndex), log)
{
this.Content = text.Substring(startIndex, stopIndex - startIndex);
}
internal override bool IsValid(out string error)
public override bool IsValid(out string errorMsg)
{
error = "";
errorMsg = "";
return true;
}
internal override string Render(ContextVariables? variables)
public string Render(ContextVariables? variables)
{
return this.Content;
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel.Orchestration;
namespace Microsoft.SemanticKernel.TemplateEngine.Blocks;
internal class ValBlock : Block, ITextRendering
{
internal override BlockTypes Type => BlockTypes.Value;
// Cache the first and last char
private readonly char _first = '\0';
private readonly char _last = '\0';
// Content, excluding start/end quote chars
private readonly string _value = string.Empty;
/// <summary>
/// Create an instance
/// </summary>
/// <param name="quotedValue">Block content, including the delimiting chars</param>
/// <param name="log">Optional logger</param>
public ValBlock(string? quotedValue, ILogger? log = null)
: base(quotedValue?.Trim(), log)
{
if (this.Content.Length < 2)
{
this.Log.LogError("A value must have single quotes or double quotes on both sides");
return;
}
this._first = this.Content[0];
this._last = this.Content[^1];
this._value = this.Content[1..^1];
}
#pragma warning disable CA2254 // error strings are used also internally, not just for logging
// ReSharper disable TemplateIsNotCompileTimeConstantProblem
public override bool IsValid(out string errorMsg)
{
errorMsg = string.Empty;
// Content includes the quotes, so it must be at least 2 chars long
if (this.Content.Length < 2)
{
errorMsg = "A value must have single quotes or double quotes on both sides";
this.Log.LogError(errorMsg);
return false;
}
// Check if delimiting chars are consistent
if (this._first != this._last)
{
errorMsg = "A value must be defined using either single quotes or double quotes, not both";
this.Log.LogError(errorMsg);
return false;
}
return true;
}
#pragma warning restore CA2254
public string Render(ContextVariables? variables)
{
return this._value;
}
public static bool HasValPrefix(string? text)
{
return !string.IsNullOrEmpty(text)
&& text.Length > 0
&& (text[0] is Symbols.DblQuote or Symbols.SglQuote);
}
}
@@ -4,48 +4,57 @@ using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel.Orchestration;
// ReSharper disable TemplateIsNotCompileTimeConstantProblem
namespace Microsoft.SemanticKernel.TemplateEngine.Blocks;
internal class VarBlock : Block
internal class VarBlock : Block, ITextRendering
{
private const char Prefix = '$';
internal override BlockTypes Type => BlockTypes.Variable;
internal string Name => this.VarName();
internal string Name { get; } = string.Empty;
internal VarBlock(string content, ILogger? log = null) : base(log)
public VarBlock(string? content, ILogger? log = null) : base(content?.Trim(), log)
{
this.Content = content;
if (this.Content.Length < 2)
{
this.Log.LogError("The variable name is empty");
return;
}
this.Name = this.Content[1..];
}
#pragma warning disable CA2254 // error strings are used also internally, not just for logging
internal override bool IsValid(out string error)
// ReSharper disable TemplateIsNotCompileTimeConstantProblem
public override bool IsValid(out string errorMsg)
{
error = string.Empty;
errorMsg = string.Empty;
if (this.Content[0] != Prefix)
if (string.IsNullOrEmpty(this.Content))
{
error = $"A variable must start with the symbol {Prefix}";
this.Log.LogError(error);
errorMsg = $"A variable must start with the symbol {Symbols.VarPrefix} and have a name";
this.Log.LogError(errorMsg);
return false;
}
if (this.Content[0] != Symbols.VarPrefix)
{
errorMsg = $"A variable must start with the symbol {Symbols.VarPrefix}";
this.Log.LogError(errorMsg);
return false;
}
if (this.Content.Length < 2)
{
error = "The variable name is empty";
this.Log.LogError(error);
errorMsg = "The variable name is empty";
this.Log.LogError(errorMsg);
return false;
}
var varName = this.VarName();
if (!Regex.IsMatch(varName, "^[a-zA-Z0-9_]*$"))
if (!Regex.IsMatch(this.Name, "^[a-zA-Z0-9_]*$"))
{
error = $"The variable name '{varName}' contains invalid characters. " +
errorMsg = $"The variable name '{this.Name}' contains invalid characters. " +
"Only alphanumeric chars and underscore are allowed.";
this.Log.LogError(error);
this.Log.LogError(errorMsg);
return false;
}
@@ -53,36 +62,20 @@ internal class VarBlock : Block
}
#pragma warning restore CA2254
internal override string Render(ContextVariables? variables)
public string Render(ContextVariables? variables)
{
if (variables == null) { return string.Empty; }
var name = this.VarName();
if (!string.IsNullOrEmpty(name))
if (string.IsNullOrEmpty(this.Name))
{
var exists = variables.Get(name, out string value);
if (!exists) { this.Log.LogWarning("Variable `{0}{1}` not found", Prefix, name); }
return exists ? value : "";
const string errMsg = "Variable rendering failed, the variable name is empty";
this.Log.LogError(errMsg);
throw new TemplateException(TemplateException.ErrorCodes.SyntaxError, errMsg);
}
this.Log.LogError("Variable rendering failed, the variable name is empty");
throw new TemplateException(
TemplateException.ErrorCodes.SyntaxError, "Variable rendering failed, the variable name is empty.");
}
var exists = variables.Get(this.Name, out string value);
if (!exists) { this.Log.LogWarning("Variable `{0}{1}` not found", Symbols.VarPrefix, this.Name); }
internal static bool HasVarPrefix(string text)
{
return !string.IsNullOrEmpty(text) && text.Length > 0 && text[0] == Prefix;
}
internal static bool IsValidVarName(string text)
{
return Regex.IsMatch(text, "^[a-zA-Z0-9_]*$");
}
private string VarName()
{
return this.Content.Length < 2 ? "" : this.Content[1..];
return exists ? value : string.Empty;
}
}
@@ -0,0 +1,256 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.SemanticKernel.TemplateEngine.Blocks;
namespace Microsoft.SemanticKernel.TemplateEngine;
/// <summary>
/// Simple tokenizer used for default SK template code language.
///
/// BNF parsed by TemplateTokenizer:
/// [template] ::= "" | [block] | [block] [template]
/// [block] ::= [sk-block] | [text-block]
/// [sk-block] ::= "{{" [variable] "}}" | "{{" [value] "}}" | "{{" [function-call] "}}"
/// [text-block] ::= [any-char] | [any-char] [text-block]
/// [any-char] ::= any char
///
/// BNF parsed by CodeTokenizer:
/// [template] ::= "" | [variable] " " [template] | [value] " " [template] | [function-call] " " [template]
/// [variable] ::= "$" [valid-name]
/// [value] ::= "'" [text] "'" | '"' [text] '"'
/// [function-call] ::= [function-id] | [function-id] [parameter]
/// [parameter] ::= [variable] | [value]
///
/// BNF parsed by dedicated blocks
/// [function-id] ::= [valid-name] | [valid-name] "." [valid-name]
/// [valid-name] ::= [valid-symbol] | [valid-symbol] [valid-name]
/// [valid-symbol] ::= [letter] | [digit] | "_"
/// [letter] ::= "a" | "b" ... | "z" | "A" | "B" ... | "Z"
/// [digit] ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
/// </summary>
internal class CodeTokenizer
{
private enum TokenTypes
{
None = 0,
Value = 1,
Variable = 2,
FunctionId = 3,
}
private readonly ILogger _log;
public CodeTokenizer(ILogger? log = null)
{
this._log = log ?? NullLogger.Instance;
}
/// <summary>
/// Tokenize a code block, without checking for syntax errors
/// </summary>
/// <param name="text">Text to parse</param>
/// <returns>A list of blocks</returns>
public List<Block> Tokenize(string? text)
{
// Remove spaces, which are ignored anyway
text = text?.Trim();
// Render NULL to ""
if (string.IsNullOrEmpty(text)) { return new List<Block>(); }
// Track what type of token we're reading
TokenTypes currentTokenType = TokenTypes.None;
// Track the content of the current token
var currentTokenContent = new StringBuilder();
char textValueDelimiter = '\0';
var blocks = new List<Block>();
char nextChar = text[0];
// Tokens must be separated by spaces, track their presence
bool spaceSeparatorFound = false;
// 1 char only edge case
if (text.Length == 1)
{
switch (nextChar)
{
case Symbols.VarPrefix:
blocks.Add(new VarBlock(text, this._log));
break;
case Symbols.DblQuote:
case Symbols.SglQuote:
blocks.Add(new ValBlock(text, this._log));
break;
default:
blocks.Add(new FunctionIdBlock(text, this._log));
break;
}
return blocks;
}
bool skipNextChar = false;
for (int nextCharCursor = 1; nextCharCursor < text.Length; nextCharCursor++)
{
char currentChar = nextChar;
nextChar = text[nextCharCursor];
if (skipNextChar)
{
skipNextChar = false;
continue;
}
// First char is easy
if (nextCharCursor == 1)
{
if (IsVarPrefix(currentChar))
{
currentTokenType = TokenTypes.Variable;
}
else if (IsQuote(currentChar))
{
currentTokenType = TokenTypes.Value;
textValueDelimiter = currentChar;
}
else
{
currentTokenType = TokenTypes.FunctionId;
}
currentTokenContent.Append(currentChar);
continue;
}
// While reading a values between quotes
if (currentTokenType == TokenTypes.Value)
{
// If the current char is escaping the next special char:
// - skip the current char (escape char)
// - add the next (special char)
// - jump to the one after (to handle "\\" properly)
if (currentChar == Symbols.EscapeChar && CanBeEscaped(nextChar))
{
currentTokenContent.Append(nextChar);
skipNextChar = true;
continue;
}
currentTokenContent.Append(currentChar);
// When we reach the end of the value
if (currentChar == textValueDelimiter)
{
blocks.Add(new ValBlock(currentTokenContent.ToString(), this._log));
currentTokenContent.Clear();
currentTokenType = TokenTypes.None;
spaceSeparatorFound = false;
}
continue;
}
// If we're not between quotes, a space signals the end of the current token
// Note: there might be multiple consecutive spaces
if (IsBlankSpace(currentChar))
{
if (currentTokenType == TokenTypes.Variable)
{
blocks.Add(new VarBlock(currentTokenContent.ToString(), this._log));
currentTokenContent.Clear();
}
else if (currentTokenType == TokenTypes.FunctionId)
{
blocks.Add(new FunctionIdBlock(currentTokenContent.ToString(), this._log));
currentTokenContent.Clear();
}
spaceSeparatorFound = true;
currentTokenType = TokenTypes.None;
continue;
}
// If we're not inside a quoted value and we're not processing a space
currentTokenContent.Append(currentChar);
if (currentTokenType == TokenTypes.None)
{
if (!spaceSeparatorFound)
{
throw new TemplateException(TemplateException.ErrorCodes.SyntaxError,
"Tokens must be separated by one space least");
}
if (IsQuote(currentChar))
{
// A quoted value starts here
currentTokenType = TokenTypes.Value;
textValueDelimiter = currentChar;
}
else if (IsVarPrefix(currentChar))
{
// A variable starts here
currentTokenType = TokenTypes.Variable;
}
else
{
// A function Id starts here
currentTokenType = TokenTypes.FunctionId;
}
}
}
// Capture last token
currentTokenContent.Append(nextChar);
switch (currentTokenType)
{
case TokenTypes.Value:
blocks.Add(new ValBlock(currentTokenContent.ToString(), this._log));
break;
case TokenTypes.Variable:
blocks.Add(new VarBlock(currentTokenContent.ToString(), this._log));
break;
case TokenTypes.FunctionId:
blocks.Add(new FunctionIdBlock(currentTokenContent.ToString(), this._log));
break;
case TokenTypes.None:
throw new TemplateException(TemplateException.ErrorCodes.SyntaxError,
"Tokens must be separated by one space least");
}
return blocks;
}
private static bool IsVarPrefix(char c)
{
return (c == Symbols.VarPrefix);
}
private static bool IsBlankSpace(char c)
{
return c is Symbols.Space or Symbols.NewLine or Symbols.CarriageReturn or Symbols.Tab;
}
private static bool IsQuote(char c)
{
return c is Symbols.DblQuote or Symbols.SglQuote;
}
private static bool CanBeEscaped(char c)
{
return c is Symbols.DblQuote or Symbols.SglQuote or Symbols.EscapeChar;
}
}
@@ -27,21 +27,21 @@ public interface IPromptTemplateEngine
/// reference with the function result.
/// </summary>
/// <param name="templateText">Prompt template (see skprompt.txt files)</param>
/// <param name="executionContext">Access into the current kernel execution context</param>
/// <param name="context">Access into the current kernel execution context</param>
/// <returns>The prompt template ready to be used for an AI request</returns>
Task<string> RenderAsync(
string templateText,
SKContext executionContext);
SKContext context);
/// <summary>
/// Given a a list of blocks render each block and compose the final result
/// Given a list of blocks render each block and compose the final result
/// </summary>
/// <param name="blocks">Template blocks generated by ExtractBlocks</param>
/// <param name="executionContext">Access into the current kernel execution context</param>
/// <param name="context">Access into the current kernel execution context</param>
/// <returns>The prompt template ready to be used for an AI request</returns>
Task<string> RenderAsync(
IList<Block> blocks,
SKContext executionContext);
SKContext context);
/// <summary>
/// Given a list of blocks, render the Variable Blocks, replacing placeholders with the actual value in memory
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
@@ -24,100 +23,83 @@ namespace Microsoft.SemanticKernel.TemplateEngine;
/// </summary>
public class PromptTemplateEngine : IPromptTemplateEngine
{
private readonly ILogger _log;
private readonly TemplateTokenizer _tokenizer;
public PromptTemplateEngine(ILogger? log = null)
{
this._log = log ?? NullLogger.Instance;
this._tokenizer = new TemplateTokenizer(this._log);
}
/// <summary>
/// Given a prompt template string, extract all the blocks (text, variables, function calls)
/// </summary>
/// <param name="templateText">Prompt template (see skprompt.txt files)</param>
/// <param name="validate">Whether to validate the blocks syntax, or just return the blocks found, which could contain invalid code</param>
/// <returns>A list of all the blocks, ie the template tokenized in text, variables and function calls</returns>
/// <inheritdoc/>
public IList<Block> ExtractBlocks(string? templateText, bool validate = true)
{
this._log.LogTrace("Extracting blocks from template: {0}", templateText);
var blocks = this.TokenizeInternal(templateText);
if (validate) { ValidateBlocksSyntax(blocks); }
var blocks = this._tokenizer.Tokenize(templateText);
if (validate)
{
foreach (var block in blocks)
{
if (!block.IsValid(out var error))
{
throw new TemplateException(TemplateException.ErrorCodes.SyntaxError, error);
}
}
}
return blocks;
}
/// <summary>
/// Given a prompt template, replace the variables with their values and execute the functions replacing their
/// reference with the function result.
/// </summary>
/// <param name="templateText">Prompt template (see skprompt.txt files)</param>
/// <param name="executionContext">Access into the current kernel execution context</param>
/// <returns>The prompt template ready to be used for an AI request</returns>
public async Task<string> RenderAsync(
string templateText,
SKContext executionContext)
/// <inheritdoc/>
public async Task<string> RenderAsync(string templateText, SKContext context)
{
this._log.LogTrace("Rendering string template: {0}", templateText);
var blocks = this.ExtractBlocks(templateText);
return await this.RenderAsync(blocks, executionContext);
return await this.RenderAsync(blocks, context);
}
/// <summary>
/// Given a a list of blocks render each block and compose the final result
/// </summary>
/// <param name="blocks">Template blocks generated by ExtractBlocks</param>
/// <param name="executionContext">Access into the current kernel execution context</param>
/// <returns>The prompt template ready to be used for an AI request</returns>
public async Task<string> RenderAsync(
IList<Block> blocks,
SKContext executionContext)
/// <inheritdoc/>
public async Task<string> RenderAsync(IList<Block> blocks, SKContext context)
{
this._log.LogTrace("Rendering list of {0} blocks", blocks.Count);
var result = new StringBuilder();
foreach (var block in blocks)
{
switch (block.Type)
switch (block)
{
case BlockTypes.Text:
result.Append(block.Content);
case ITextRendering staticBlock:
result.Append(staticBlock.Render(context.Variables));
break;
case BlockTypes.Variable:
result.Append(block.Render(executionContext.Variables));
case ICodeRendering dynamicBlock:
result.Append(await dynamicBlock.RenderCodeAsync(context));
break;
case BlockTypes.Code:
result.Append(await block.RenderCodeAsync(executionContext));
break;
case BlockTypes.Undefined:
default:
throw new InvalidEnumArgumentException(nameof(blocks), (int)block.Type, typeof(BlockTypes));
const string error = "Unexpected block type, the block doesn't have a rendering method";
this._log.LogError(error);
throw new TemplateException(TemplateException.ErrorCodes.UnexpectedBlockType, error);
}
}
// TODO: remove PII, allow tracing prompts differently
this._log.LogDebug("Rendered prompt: {0}", result);
return result.ToString();
}
/// <summary>
/// Given a list of blocks, render the Variable Blocks, replacing placeholders with the actual value in memory
/// </summary>
/// <param name="blocks">List of blocks, typically all the blocks found in a template</param>
/// <param name="variables">Container of all the temporary variables known to the kernel</param>
/// <returns>An updated list of blocks where Variable Blocks have rendered to Text Blocks</returns>
/// <inheritdoc/>
public IList<Block> RenderVariables(IList<Block> blocks, ContextVariables? variables)
{
this._log.LogTrace("Rendering variables");
return blocks.Select(block => block.Type != BlockTypes.Variable
? block
: new TextBlock(block.Render(variables), this._log)).ToList();
: new TextBlock(((ITextRendering)block).Render(variables), this._log)).ToList();
}
/// <summary>
/// Given a list of blocks, render the Code Blocks, executing the functions and replacing placeholders with the functions result
/// </summary>
/// <param name="blocks">List of blocks, typically all the blocks found in a template</param>
/// <param name="executionContext">Access into the current kernel execution context</param>
/// <returns>An updated list of blocks where Code Blocks have rendered to Text Blocks</returns>
/// <inheritdoc/>
public async Task<IList<Block>> RenderCodeAsync(
IList<Block> blocks,
SKContext executionContext)
@@ -132,127 +114,11 @@ public class PromptTemplateEngine : IPromptTemplateEngine
}
else
{
var codeResult = await block.RenderCodeAsync(executionContext);
var codeResult = await ((ICodeRendering)block).RenderCodeAsync(executionContext);
updatedBlocks.Add(new TextBlock(codeResult, this._log));
}
}
return updatedBlocks;
}
#region private ================================================================================
private readonly ILogger _log;
// Blocks delimitation
private const char Starter = '{';
private const char Ender = '}';
private IList<Block> TokenizeInternal(string? template)
{
// An empty block consists of 4 chars: "{{}}"
const int EMPTY_CODE_BLOCK_LENGTH = 4;
// A block shorter than 5 chars is either empty or invalid, e.g. "{{ }}" and "{{$}}"
const int MIN_CODE_BLOCK_LENGTH = EMPTY_CODE_BLOCK_LENGTH + 1;
// Render NULL to ""
if (template == null)
{
return new List<Block> { new TextBlock("", this._log) };
}
// If the template is "empty" return the content as a text block
if (template.Length < MIN_CODE_BLOCK_LENGTH)
{
return new List<Block> { new TextBlock(template, this._log) };
}
var blocks = new List<Block>();
var cursor = 0;
var endOfLastBlock = 0;
var startPos = 0;
var startFound = false;
while (cursor < template.Length - 1)
{
// When "{{" is found
if (template[cursor] == Starter && template[cursor + 1] == Starter)
{
startPos = cursor;
startFound = true;
}
// When "}}" is found
else if (startFound && template[cursor] == Ender && template[cursor + 1] == Ender)
{
// If there is plain text between the current var/code block and the previous one, capture that as a TextBlock
if (startPos > endOfLastBlock)
{
blocks.Add(new TextBlock(template, endOfLastBlock, startPos, this._log));
}
// Skip ahead to the second "}" of "}}"
cursor++;
// Extract raw block
var contentWithDelimiters = SubStr(template, startPos, cursor + 1);
// Remove "{{" and "}}" delimiters and trim empty chars
var contentWithoutDelimiters = contentWithDelimiters
.Substring(2, contentWithDelimiters.Length - EMPTY_CODE_BLOCK_LENGTH).Trim();
if (contentWithoutDelimiters.Length == 0)
{
// If what is left is empty, consider the raw block a Text Block
blocks.Add(new TextBlock(contentWithDelimiters, this._log));
}
else
{
// If the block starts with "$" it's a variable
if (VarBlock.HasVarPrefix(contentWithoutDelimiters))
{
// Note: validation is delayed to the time VarBlock is rendered
blocks.Add(new VarBlock(contentWithoutDelimiters, this._log));
}
else
{
// Note: validation is delayed to the time CodeBlock is rendered
blocks.Add(new CodeBlock(contentWithoutDelimiters, this._log));
}
}
endOfLastBlock = cursor + 1;
startFound = false;
}
cursor++;
}
// If there is something left after the last block, capture it as a TextBlock
if (endOfLastBlock < template.Length)
{
blocks.Add(new TextBlock(template, endOfLastBlock, template.Length, this._log));
}
return blocks;
}
private static string SubStr(string text, int startIndex, int stopIndex)
{
return text.Substring(startIndex, stopIndex - startIndex);
}
private static void ValidateBlocksSyntax(IList<Block> blocks)
{
foreach (var block in blocks)
{
if (!block.IsValid(out var error))
{
throw new TemplateException(TemplateException.ErrorCodes.SyntaxError, error);
}
}
}
#endregion
}
@@ -25,15 +25,20 @@ public class TemplateException : Exception<TemplateException.ErrorCodes>
/// </summary>
SyntaxError = 0,
/// <summary>
/// The block type produced be the tokenizer was not expected
/// </summary>
UnexpectedBlockType = 1,
/// <summary>
/// The template requires an unknown function.
/// </summary>
FunctionNotFound = 1,
FunctionNotFound = 2,
/// <summary>
/// The template execution failed, e.g. a function call threw an exception.
/// </summary>
RuntimeError = 2,
RuntimeError = 3,
}
/// <summary>
@@ -0,0 +1,233 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.SemanticKernel.TemplateEngine.Blocks;
namespace Microsoft.SemanticKernel.TemplateEngine;
/// <summary>
/// Simple tokenizer used for default SK template language.
///
/// BNF parsed by TemplateTokenizer:
/// [template] ::= "" | [block] | [block] [template]
/// [block] ::= [sk-block] | [text-block]
/// [sk-block] ::= "{{" [variable] "}}" | "{{" [value] "}}" | "{{" [function-call] "}}"
/// [text-block] ::= [any-char] | [any-char] [text-block]
/// [any-char] ::= any char
///
/// BNF parsed by CodeTokenizer:
/// [template] ::= "" | [variable] " " [template] | [value] " " [template] | [function-call] " " [template]
/// [variable] ::= "$" [valid-name]
/// [value] ::= "'" [text] "'" | '"' [text] '"'
/// [function-call] ::= [function-id] | [function-id] [parameter]
/// [parameter] ::= [variable] | [value]
///
/// BNF parsed by dedicated blocks
/// [function-id] ::= [valid-name] | [valid-name] "." [valid-name]
/// [valid-name] ::= [valid-symbol] | [valid-symbol] [valid-name]
/// [valid-symbol] ::= [letter] | [digit] | "_"
/// [letter] ::= "a" | "b" ... | "z" | "A" | "B" ... | "Z"
/// [digit] ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
/// </summary>
internal class TemplateTokenizer
{
/// <summary>
/// Create a new instance of SK tokenizer
/// </summary>
/// <param name="log"></param>
public TemplateTokenizer(ILogger? log = null)
{
this._log = log ?? NullLogger.Instance;
this._codeTokenizer = new CodeTokenizer(this._log);
}
/// <summary>
/// Extract blocks from the given text
/// </summary>
/// <param name="text">Text to parse</param>
/// <returns>List of blocks found in the text</returns>
public IList<Block> Tokenize(string? text)
{
// An empty block consists of 4 chars: "{{}}"
const int EMPTY_CODE_BLOCK_LENGTH = 4;
// A block shorter than 5 chars is either empty or invalid, e.g. "{{ }}" and "{{$}}"
const int MIN_CODE_BLOCK_LENGTH = EMPTY_CODE_BLOCK_LENGTH + 1;
// Render NULL to ""
if (string.IsNullOrEmpty(text))
{
return new List<Block> { new TextBlock(string.Empty, this._log) };
}
// If the template is "empty" return the content as a text block
if (text.Length < MIN_CODE_BLOCK_LENGTH)
{
return new List<Block> { new TextBlock(text, this._log) };
}
var blocks = new List<Block>();
var endOfLastBlock = 0;
var blockStartPos = 0;
var blockStartFound = false;
var insideTextValue = false;
var textValueDelimiter = '\0';
bool skipNextChar = false;
char nextChar = text[0];
for (int nextCharCursor = 1; nextCharCursor < text.Length; nextCharCursor++)
{
int currentCharPos = nextCharCursor - 1;
int cursor = nextCharCursor;
char currentChar = nextChar;
nextChar = text[nextCharCursor];
if (skipNextChar)
{
skipNextChar = false;
continue;
}
// When "{{" is found outside a value
// Note: "{{ {{x}}" => ["{{ ", "{{x}}"]
if (!insideTextValue && currentChar == Symbols.BlockStarter && nextChar == Symbols.BlockStarter)
{
// A block starts at the first "{"
blockStartPos = currentCharPos;
blockStartFound = true;
}
// After having found '{{'
if (blockStartFound)
{
// While inside a text value, when the end quote is found
if (insideTextValue)
{
if (currentChar == Symbols.EscapeChar && CanBeEscaped(nextChar))
{
skipNextChar = true;
continue;
}
if (currentChar == textValueDelimiter)
{
insideTextValue = false;
}
}
else
{
// A value starts here
if (IsQuote(currentChar))
{
insideTextValue = true;
textValueDelimiter = currentChar;
}
// If the block ends here
else if (currentChar == Symbols.BlockEnder && nextChar == Symbols.BlockEnder)
{
// If there is plain text between the current var/val/code block and the previous one, capture that as a TextBlock
if (blockStartPos > endOfLastBlock)
{
blocks.Add(new TextBlock(text, endOfLastBlock, blockStartPos, this._log));
}
// Extract raw block
var contentWithDelimiters = SubStr(text, blockStartPos, cursor + 1);
// Remove "{{" and "}}" delimiters and trim empty chars
var contentWithoutDelimiters = contentWithDelimiters
.Substring(2, contentWithDelimiters.Length - EMPTY_CODE_BLOCK_LENGTH)
.Trim();
if (contentWithoutDelimiters.Length == 0)
{
// If what is left is empty, consider the raw block a Text Block
blocks.Add(new TextBlock(contentWithDelimiters, this._log));
}
else
{
List<Block> codeBlocks = this._codeTokenizer.Tokenize(contentWithoutDelimiters);
switch (codeBlocks[0].Type)
{
case BlockTypes.Variable:
if (codeBlocks.Count > 1)
{
throw new TemplateException(TemplateException.ErrorCodes.SyntaxError,
$"Invalid token detected after the variable: {contentWithoutDelimiters}");
}
blocks.Add(codeBlocks[0]);
break;
case BlockTypes.Value:
if (codeBlocks.Count > 1)
{
throw new TemplateException(TemplateException.ErrorCodes.SyntaxError,
$"Invalid token detected after the value: {contentWithoutDelimiters}");
}
blocks.Add(codeBlocks[0]);
break;
case BlockTypes.FunctionId:
if (codeBlocks.Count > 2)
{
throw new TemplateException(TemplateException.ErrorCodes.SyntaxError,
$"Functions support only one parameter: {contentWithoutDelimiters}");
}
blocks.Add(new CodeBlock(codeBlocks, contentWithoutDelimiters, this._log));
break;
case BlockTypes.Code:
case BlockTypes.Text:
case BlockTypes.Undefined:
default:
throw new TemplateException(TemplateException.ErrorCodes.UnexpectedBlockType,
$"Code tokenizer returned an incorrect first token type {codeBlocks[0].Type:G}");
}
}
endOfLastBlock = cursor + 1;
blockStartFound = false;
}
}
}
}
// If there is something left after the last block, capture it as a TextBlock
if (endOfLastBlock < text.Length)
{
blocks.Add(new TextBlock(text, endOfLastBlock, text.Length, this._log));
}
return blocks;
}
#region private ================================================================================
private readonly ILogger _log;
private readonly CodeTokenizer _codeTokenizer;
private static string SubStr(string text, int startIndex, int stopIndex)
{
return text.Substring(startIndex, stopIndex - startIndex);
}
private static bool IsQuote(char c)
{
return c is Symbols.DblQuote or Symbols.SglQuote;
}
private static bool CanBeEscaped(char c)
{
return c is Symbols.DblQuote or Symbols.SglQuote or Symbols.EscapeChar;
}
#endregion
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
namespace KernelHttpServer.Config;
internal class Constants
{
public class SKHttpHeaders
@@ -23,7 +23,6 @@ public class SemanticKernelEndpoint
this._memoryStore = memoryStore;
}
[Function("InvokeFunction")]
public async Task<HttpResponseData> InvokeFunctionAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "skills/{skillName}/invoke/{functionName}")]
@@ -70,8 +70,8 @@ public static class Example15_MemorySkill
const string RECALL_FUNCTION_DEFINITION = @"
Consider only the facts below when answering questions.
About me: {{recall $fact1}}
About me: {{recall $fact2}}
About me: {{recall 'where did I grow up?'}}
About me: {{recall 'where do I live?'}}
Question: {{$query}}
@@ -80,8 +80,6 @@ Answer:
var aboutMeOracle = kernel.CreateSemanticFunction(RECALL_FUNCTION_DEFINITION, maxTokens: 100);
context["fact1"] = "where did I grow up?";
context["fact2"] = "where do I live?";
context["query"] = "Do I live in the same town where I grew up?";
context[TextMemorySkill.RelevanceParam] = "0.8";