Skip to content

.Net: Update LiquidPromptTemplate to use Fluid instead of Scriban #6320

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
<PackageVersion Include="protobuf-net" Version="3.2.30" />
<PackageVersion Include="protobuf-net.Reflection" Version="3.2.12" />
<PackageVersion Include="YamlDotNet" Version="15.1.2" />
<PackageVersion Include="Scriban" Version="5.10.0" />
<PackageVersion Include="Fluid.Core" Version="2.10.0" />
<!-- Memory stores -->
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.41.0-preview.0" />
<PackageVersion Include="Pgvector" Version="0.2.0" />
Expand Down
102 changes: 67 additions & 35 deletions dotnet/src/Extensions/PromptTemplates.Liquid/LiquidPromptTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Scriban;
using Scriban.Syntax;
using Fluid;
using Fluid.Ast;

namespace Microsoft.SemanticKernel.PromptTemplates.Liquid;

Expand All @@ -18,12 +17,18 @@ namespace Microsoft.SemanticKernel.PromptTemplates.Liquid;
/// </summary>
internal sealed partial class LiquidPromptTemplate : IPromptTemplate
{
private static readonly FluidParser s_parser = new();
private static readonly TemplateOptions s_templateOptions = new()
{
MemberAccessStrategy = new UnsafeMemberAccessStrategy() { MemberNameStrategy = MemberNameStrategies.SnakeCase },
};

private const string ReservedString = "&#58;";
private const string ColonString = ":";
private const char LineEnding = '\n';
private readonly PromptTemplateConfig _config;
private readonly bool _allowDangerouslySetContent;
private readonly Template _liquidTemplate;
private readonly IFluidTemplate _liquidTemplate;
private readonly Dictionary<string, object> _inputVariables;

#if NET
Expand Down Expand Up @@ -55,12 +60,12 @@ public LiquidPromptTemplate(PromptTemplateConfig config, bool allowDangerouslySe

// Parse the template now so we can check for errors, understand variable usage, and
// avoid having to parse on each render.
this._liquidTemplate = Template.ParseLiquid(config.Template);
if (this._liquidTemplate.HasErrors)
if (!s_parser.TryParse(config.Template, out this._liquidTemplate, out string error))
{
throw new ArgumentException($"The template could not be parsed:{Environment.NewLine}{string.Join(Environment.NewLine, this._liquidTemplate.Messages)}");
throw new ArgumentException(error is not null ?
$"The template could not be parsed:{Environment.NewLine}{error}" :
"The template could not be parsed.");
}
Debug.Assert(this._liquidTemplate.Page is not null);

// Ideally the prompty author would have explicitly specified input variables. If they specified any,
// assume they specified them all. If they didn't, heuristically try to find the variables, looking for
Expand Down Expand Up @@ -92,7 +97,7 @@ public async Task<string> RenderAsync(Kernel kernel, KernelArguments? arguments
{
Verify.NotNull(kernel);
cancellationToken.ThrowIfCancellationRequested();
var variables = this.GetVariables(arguments);
var variables = this.GetTemplateContext(arguments);
var renderedResult = this._liquidTemplate.Render(variables);

// parse chat history
Expand Down Expand Up @@ -154,9 +159,9 @@ private string ReplaceReservedStringBackToColonIfNeeded(string text)
/// <summary>
/// Gets the variables for the prompt template, including setting any default values from the prompt config.
/// </summary>
private Dictionary<string, object?> GetVariables(KernelArguments? arguments)
private TemplateContext GetTemplateContext(KernelArguments? arguments)
{
var result = new Dictionary<string, object?>();
var ctx = new TemplateContext(s_templateOptions);

foreach (var p in this._config.InputVariables)
{
Expand All @@ -165,7 +170,7 @@ private string ReplaceReservedStringBackToColonIfNeeded(string text)
continue;
}

result[p.Name] = p.Default;
ctx.SetValue(p.Name, p.Default);
}

if (arguments is not null)
Expand All @@ -177,17 +182,17 @@ private string ReplaceReservedStringBackToColonIfNeeded(string text)
var value = (object)kvp.Value;
if (this.ShouldReplaceColonToReservedString(this._config, kvp.Key, kvp.Value))
{
result[kvp.Key] = value.ToString()?.Replace(ColonString, ReservedString);
ctx.SetValue(kvp.Key, value.ToString()?.Replace(ColonString, ReservedString));
}
else
{
result[kvp.Key] = value;
ctx.SetValue(kvp.Key, value);
}
}
}
}

return result;
return ctx;
}

private bool ShouldReplaceColonToReservedString(PromptTemplateConfig promptTemplateConfig, string propertyName, object? propertyValue)
Expand All @@ -209,20 +214,23 @@ private bool ShouldReplaceColonToReservedString(PromptTemplateConfig promptTempl
}

/// <summary>
/// Visitor for <see cref="ScriptPage"/> looking for variables that are only
/// Visitor for <see cref="IFluidTemplate"/> looking for variables that are only
/// ever read and appear to represent very simple strings. If any variables
/// other than that are found, none are returned.
/// other than that are found, none are returned. This only handles very basic
/// cases where the template doesn't contain any more complicated constructs;
/// the heuristic can be improved over time.
/// </summary>
private sealed class SimpleVariablesVisitor : ScriptVisitor
private sealed class SimpleVariablesVisitor : AstVisitor
{
private readonly HashSet<string> _variables = new(StringComparer.OrdinalIgnoreCase);
private readonly Stack<Statement> _statementStack = new();
private bool _valid = true;

public static HashSet<string> InferInputs(Template template)
public static HashSet<string> InferInputs(IFluidTemplate template)
{
var visitor = new SimpleVariablesVisitor();

template.Page.Accept(visitor);
visitor.VisitTemplate(template);
if (!visitor._valid)
{
visitor._variables.Clear();
Expand All @@ -231,27 +239,51 @@ public static HashSet<string> InferInputs(Template template)
return visitor._variables;
}

public override void Visit(ScriptVariableGlobal node)
public override Statement Visit(Statement statement)
{
if (!this._valid)
{
return statement;
}

this._statementStack.Push(statement);
try
{
return base.Visit(statement);
}
finally
{
this._statementStack.Pop();
}
}

protected override Expression VisitMemberExpression(MemberExpression memberExpression)
{
if (this._valid)
if (memberExpression.Segments.Count == 1 && memberExpression.Segments[0] is IdentifierSegment id)
{
switch (node.Parent)
bool isValid = true;

if (this._statementStack.Count > 0)
{
case ScriptAssignExpression assign when ReferenceEquals(assign.Target, node):
case ScriptForStatement forLoop:
case ScriptMemberExpression member:
// Unsupported use found; bail.
this._valid = false;
return;

default:
// Reading from a simple variable.
this._variables.Add(node.Name);
break;
switch (this._statementStack.Peek())
{
case ForStatement:
case AssignStatement assign when string.Equals(id.Identifier, assign.Identifier, StringComparison.OrdinalIgnoreCase):
isValid = false;
break;
}
}

base.DefaultVisit(node);
if (isValid)
{
this._variables.Add(id.Identifier);
return base.VisitMemberExpression(memberExpression);
}
}

// Found something unsupported. Bail.
this._valid = false;
return memberExpression;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@

<ItemGroup>
<ProjectReference Include="..\..\SemanticKernel.Core\SemanticKernel.Core.csproj" />
<PackageReference Include="Scriban" />
<PackageReference Include="Fluid.Core" />
</ItemGroup>
</Project>
Loading