Skip to content
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
7 changes: 5 additions & 2 deletions DotPrompt.Tests/DotPrompt.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand All @@ -35,6 +35,9 @@
<None Update="duplicate-name-prompts\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="multiple-version-prompts\*">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>

<ItemGroup>
Expand Down
30 changes: 24 additions & 6 deletions DotPrompt.Tests/Extensions/OpenAi/OpenAiExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Reflection;
using DotPrompt.Extensions.OpenAi;
using OpenAI.Chat;

Expand Down Expand Up @@ -105,13 +106,10 @@ public void ToOpenAiChatCompletionOptions_WithJsonSchemaFormat_ReturnsAValidOpti

const string expectedSchema = """{"type":"object","required":["field1"],"properties":{"field1":{"type":"string","description":"An example description for the field"},"field2":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}""";

var jsonSchemaProperty = options.ResponseFormat.GetType().GetProperty("JsonSchema");
var jsonSchemaValue = jsonSchemaProperty!.GetValue(options.ResponseFormat);
var jsonSchemaValue = GetInternalProperty<object>(options.ResponseFormat, "JsonSchema");
var schemaValue = GetInternalProperty<BinaryData>(jsonSchemaValue, "Schema");

var schemaProperty = jsonSchemaValue!.GetType().GetProperty("Schema");
var schemaValue = schemaProperty!.GetValue(jsonSchemaValue) as BinaryData;

var optionsSchema = schemaValue!.ToString();
var optionsSchema = schemaValue.ToString();

Assert.Equal(expectedSchema, optionsSchema);

Expand Down Expand Up @@ -159,4 +157,24 @@ public void ToOpenAiChatCompletionOptions_WithInvalidFormat_ThrowsAnException()
var exception = Assert.Throws<DotPromptException>(() => promptFileMock.ToOpenAiChatCompletionOptions());
Assert.Contains("The requested output format is not available", exception.Message);
}

private static T GetInternalProperty<T>(object obj, string propertyName)
{
var property = obj.GetType().GetProperty(propertyName,
BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);

if (property == null)
{
throw new InvalidOperationException($"Property '{propertyName}' not found on type '{obj.GetType().Name}'");
}

var value = property.GetValue(obj);

if (value is T result)
{
return result;
}

throw new InvalidOperationException($"Property '{propertyName}' is not of expected type '{typeof(T).Name}'");
}
}
27 changes: 26 additions & 1 deletion DotPrompt.Tests/PromptFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public void FromFile_BasicPrompt_ProducesValidPromptFile()
};

Assert.Equal("basic", promptFile.Name);
Assert.Equal(1, promptFile.Version);

Assert.NotNull(promptFile.Model);
Assert.Equal("claude-3-5-sonnet-latest", promptFile.Model);
Expand Down Expand Up @@ -213,6 +214,30 @@ public void FromStream_WithMissingModelName_IsPersistedAsNullValue()
Assert.Null(promptFile.Model);
}

[Theory]
[InlineData(int.MinValue, true)]
[InlineData(-1, true)]
[InlineData(0, false)]
[InlineData(1, false)]
public void FromStream_WithInvalidVersion_ThrowsAnException(int version, bool throwsException)
{
var content = $"name: test\nversion: {version}\nprompts:\n system: System prompt\n user: User prompt";
using var ms = new MemoryStream(Encoding.UTF8.GetBytes(content));
ms.Seek(0, SeekOrigin.Begin);

var act = () => PromptFile.FromStream("test", ms);

if (throwsException)
{
var exception = Assert.Throws<DotPromptException>(act);
Assert.Contains("The version of the prompt file cannot be negative", exception.Message);
}
else
{
act();
}
}

[Fact]
public void GenerateUserPrompt_UsingDefaults_CorrectlyGeneratesPromptFromTemplate()
{
Expand Down Expand Up @@ -368,7 +393,7 @@ public void ToStream_WithValidInput_ProducesExpectedContent()
}
};

var expected = "name: test\nconfig:\n input:\n parameters:\n test?: string\n outputFormat: Json\n maxTokens: 500\nprompts:\n system: system prompt\n user: user prompt\n";
const string expected = "name: test\nversion: 1\nconfig:\n input:\n parameters:\n test?: string\n outputFormat: Json\n maxTokens: 500\nprompts:\n system: system prompt\n user: user prompt\n";

var ms = new MemoryStream();
promptFile.ToStream(ms);
Expand Down
63 changes: 63 additions & 0 deletions DotPrompt.Tests/PromptManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ public void PromptManager_WithPathSpecified_LoadsPromptsFromSpecifiedLocation()
Assert.Contains("basic", actualPrompts);
Assert.Contains("example-with-name", actualPrompts);
}

[Fact]
public void PromptManager_ListPromptFileNamesWithVersions_ReturnsListOfPromptFileNamesAndVersions()
{
var manager = new PromptManager();

var expectedPrompts = new List<string> { "basic:1", "example-with-name:1" };
var actualPrompts = manager.ListPromptFileNamesWithVersions().ToList();

Assert.Equal(expectedPrompts.Count, actualPrompts.Count);
foreach (var expectedPrompt in expectedPrompts)
{
Assert.Contains(expectedPrompt, actualPrompts);
}
}

[Fact]
public void PromptManager_WithDuplicateNames_ThrowsException()
Expand All @@ -53,6 +68,54 @@ public void PromptManager_WithDuplicateNames_ThrowsException()
Assert.Contains("a duplicate exists", exception.Message);
}

[Fact]
public void PromptManager_WithDifferentVersions_LoadsSuccessfully()
{
var manager = new PromptManager("multiple-version-prompts");

var expectedPrompts = new List<string> { "basic:1", "basic:2" };
var actualPrompts = manager.ListPromptFileNamesWithVersions().ToList();

Assert.Equal(expectedPrompts.Count, actualPrompts.Count);
foreach (var expectedPrompt in expectedPrompts)
{
Assert.Contains(expectedPrompt, actualPrompts);
}
}

[Fact]
public void PromptManager_WhenRequestedByName_ReturnsLatestVersion()
{
var manager = new PromptManager("multiple-version-prompts");

var expectedPrompt = PromptFile.FromFile("multiple-version-prompts/basic-new.prompt");
var actualPrompt = manager.GetPromptFile("basic");

Assert.Equivalent(expectedPrompt, actualPrompt, strict: true);
}

[Fact]
public void PromptManager_WhenRequestedByNameAndVersion_ReturnsCorrectVersion()
{
var manager = new PromptManager("multiple-version-prompts");

var expectedPrompt = PromptFile.FromFile("multiple-version-prompts/basic.prompt");
var actualPrompt = manager.GetPromptFile("basic", 1);

Assert.Equivalent(expectedPrompt, actualPrompt, strict: true);
}

[Fact]
public void PromptManager_WhenRequestedByNameAndInvalidVersion_ThrowsException()
{
var manager = new PromptManager("multiple-version-prompts");

var act = () => manager.GetPromptFile("basic", 3);

var exception = Assert.Throws<DotPromptException>(act);
Assert.Equal("No prompt file with that name and version has been loaded", exception.Message);
}

[Fact]
public void GetPromptFile_WhenRequestedWithValidName_LoadsExpectedPromptFile()
{
Expand Down
20 changes: 20 additions & 0 deletions DotPrompt.Tests/multiple-version-prompts/basic-new.prompt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: basic
version: 2
config:
outputFormat: text
temperature: 0.9
maxTokens: 500
input:
parameters:
country: string
style?: string
default:
country: Malta
prompts:
system: |
You are a helpful AI assistant that enjoys making capybara related puns. You should work as many into your response as possible
user: |
I am looking at going on holiday to {{ country }} and would like to know more about it, what can you tell me?
{% if style -%}
Can you answer in the style of a {{ style }}
{% endif -%}
20 changes: 20 additions & 0 deletions DotPrompt.Tests/multiple-version-prompts/basic.prompt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: basic
version: 1
config:
outputFormat: text
temperature: 0.9
maxTokens: 500
input:
parameters:
country: string
style?: string
default:
country: Malta
prompts:
system: |
You are a helpful AI assistant that enjoys making penguin related puns. You should work as many into your response as possible
user: |
I am looking at going on holiday to {{ country }} and would like to know more about it, what can you tell me?
{% if style -%}
Can you answer in the style of a {{ style }}
{% endif -%}
6 changes: 3 additions & 3 deletions DotPrompt/DotPrompt.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Fluid.Core" Version="2.23.0" />
<PackageReference Include="JsonSchema.Net" Version="7.3.4" />
<PackageReference Include="OpenAI" Version="2.1.0" />
<PackageReference Include="Fluid.Core" Version="2.25.0" />
<PackageReference Include="JsonSchema.Net" Version="7.4.0" />
<PackageReference Include="OpenAI" Version="2.3.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>

Expand Down
11 changes: 11 additions & 0 deletions DotPrompt/PromptFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ public partial class PromptFile
/// </summary>
public required string Name { get; set; }

/// <summary>
/// Gets, sets the version of the prompt file.
/// </summary>
public int Version { get; set; } = 1;

/// <summary>
/// Gets, sets the name of the model (or deployment) the prompt should be executed using
/// </summary>
Expand Down Expand Up @@ -113,6 +118,12 @@ public static PromptFile FromStream(string name, Stream inputStream)
{
promptFile.Name = name;
}

// If the prompt version is negative, then throw an exception
if (promptFile.Version < 0)
{
throw new DotPromptException("The version of the prompt file cannot be negative");
}

// If the prompt output configuration is null then create a new one and set the output format. This is to handle
// instances where the output format is slightly older and is set at the top level
Expand Down
59 changes: 49 additions & 10 deletions DotPrompt/PromptManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@

namespace DotPrompt;

/// <summary>
/// Identifies a .prompt file uniquely using its name and version
/// </summary>
/// <param name="Name">The name of the prompt file</param>
/// <param name="Version">The prompt file version</param>
public record PromptFileIdentifier(string Name, int Version);

/// <summary>
/// Manages loading and accessing of .prompt files from a specified directory.
/// </summary>
public class PromptManager : IPromptManager
{
private readonly ConcurrentDictionary<string, PromptFile> _promptFiles = new();
private readonly ConcurrentDictionary<PromptFileIdentifier, PromptFile> _promptFiles = new();

/// <summary>
/// Creates a new instance of the <see cref="PromptManager"/> using a default instance of the
Expand All @@ -30,35 +37,67 @@ public PromptManager(IPromptStore promptStore)
{
foreach (var promptFile in promptStore.Load())
{
if (!_promptFiles.TryAdd(promptFile.Name, promptFile))
if (!_promptFiles.TryAdd(new PromptFileIdentifier(promptFile.Name, promptFile.Version), promptFile))
{
throw new DotPromptException($"Unable to add prompt file with name '{promptFile.Name}' as a duplicate exists");
throw new DotPromptException($"Unable to add prompt file with name '{promptFile.Name}' and version {promptFile.Version} as a duplicate exists");
}
}
}

/// <summary>
/// Retrieves a <see cref="PromptFile"/> by its name
/// Retrieves a <see cref="PromptFile"/> by its name. If multiple versions of the prompt file are found, the
/// latest version is returned.
/// </summary>
/// <param name="name">The name of the prompt file to retrieve</param>
/// <returns>The <see cref="PromptFile"/> with the specified name</returns>
/// <exception cref="DotPromptException">Thrown when no prompt file with the specified name is found</exception>
public PromptFile GetPromptFile(string name)
{
if (_promptFiles.TryGetValue(name, out var promptFile))
{
return promptFile;
}
var promptFilesWithName = _promptFiles
.Where(kvp => kvp.Key.Name == name)
.OrderByDescending(kvp => kvp.Key.Version)
.ToList();

throw new DotPromptException("No prompt file with that name has been loaded");
return promptFilesWithName.Count == 0
? throw new DotPromptException("No prompt file with that name has been loaded")
: promptFilesWithName[0].Value;
}

/// <summary>
/// Retrieves a <see cref="PromptFile"/> by its name and version.
/// </summary>
/// <param name="name">The name of the prompt file to retrieve</param>
/// <param name="version">The version of the prompt file to retrieve</param>
/// <returns>The <see cref="PromptFile"/> with the specified name and version</returns>
/// <exception cref="DotPromptException">Thrown when no prompt file with the specified name and version is found</exception>
public PromptFile GetPromptFile(string name, int version)
{
return _promptFiles.TryGetValue(new PromptFileIdentifier(name, version), out var promptFile)
? promptFile
: throw new DotPromptException("No prompt file with that name and version has been loaded");
}

/// <summary>
/// Lists the names of all loaded prompt files.
/// </summary>
/// <returns>An enumerable collection of prompt file names.</returns>
public IEnumerable<string> ListPromptFileNames()
{
return _promptFiles.Keys.Select(k => k);
return _promptFiles
.DistinctBy(kvp => kvp.Key.Name)
.OrderBy(kvp => kvp.Key.Name)
.Select(kvp => $"{kvp.Key.Name}");
}

/// <summary>
/// Lists the names of all loaded prompts with their versions.
/// </summary>
/// <returns>An enumerable collection of prompt file names and versions.</returns>
public IEnumerable<string> ListPromptFileNamesWithVersions()
{
return _promptFiles
.OrderBy(kvp => kvp.Key.Name)
.ThenByDescending(kvp => kvp.Key.Version)
.Select(kvp => $"{kvp.Key.Name}:{kvp.Key.Version}");
}
}
Loading