Skip to content

Commit 8a2f989

Browse files
authored
Implement support for versioned prompt files (#13)
* Update package dependencies and refactor schema property access - Updated core and test project dependencies: Fluid.Core to 2.25.0, JsonSchema.Net to 7.4.0, OpenAI to 2.3.0, Microsoft.NET.Test.Sdk to 17.14.1, and xUnit packages to their latest versions. - Refactored internal schema property access in OpenAi options tests to use a helper method for better maintainability. - Introduced a private helper method `GetInternalProperty` for consistent property access logic. * Add prompt version support and enhance PromptManager functionality - Introduced a `Version` property to `PromptFile` with validation for negative values. - Updated `PromptManager` to uniquely identify prompt files by name and version using the new `PromptFileIdentifier` record. - Added `GetPromptFile` overload for version-specific retrieval and a method to list prompt filenames with versions. - Updated tests to cover new functionalities, ensuring proper behavior and version management. * Add support for multiple prompt versions and accompanying tests - Added `multiple-version-prompts` directory with sample prompt versions. - Updated project file to include new directory in the build output. - Introduced `PromptManager_WithDifferentVersions_LoadsSuccessfully` test to validate version handling. * Add unit tests for PromptManager version handling and invalid version exceptions - Added tests verifying PromptManager retrieves correct prompt file when requested by name and version. - Introduced tests confirming exceptions are thrown for invalid versions in PromptManager and PromptFile. * Add version assertion to `PromptFileTests` to verify default `Version` property values * Document `version` property usage in README - Added explanation of the optional `version` property in configuration. - Included examples of version-specific prompt file loading in code snippets. * Add Codecov configuration with coverage threshold settings - Introduced `codecov.yml` to manage Codecov project and patch thresholds, set at 3% for both.
1 parent cb205d5 commit 8a2f989

11 files changed

Lines changed: 239 additions & 22 deletions

File tree

DotPrompt.Tests/DotPrompt.Tests.csproj

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
</PropertyGroup>
1111

1212
<ItemGroup>
13-
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
13+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
1414
<PackageReference Include="xunit" Version="2.9.3" />
15-
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
15+
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
1616
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1717
<PrivateAssets>all</PrivateAssets>
1818
</PackageReference>
@@ -35,6 +35,9 @@
3535
<None Update="duplicate-name-prompts\*">
3636
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
3737
</None>
38+
<None Update="multiple-version-prompts\*">
39+
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
40+
</None>
3841
</ItemGroup>
3942

4043
<ItemGroup>

DotPrompt.Tests/Extensions/OpenAi/OpenAiExtensionsTests.cs

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Reflection;
12
using DotPrompt.Extensions.OpenAi;
23
using OpenAI.Chat;
34

@@ -105,13 +106,10 @@ public void ToOpenAiChatCompletionOptions_WithJsonSchemaFormat_ReturnsAValidOpti
105106

106107
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}""";
107108

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

111-
var schemaProperty = jsonSchemaValue!.GetType().GetProperty("Schema");
112-
var schemaValue = schemaProperty!.GetValue(jsonSchemaValue) as BinaryData;
113-
114-
var optionsSchema = schemaValue!.ToString();
112+
var optionsSchema = schemaValue.ToString();
115113

116114
Assert.Equal(expectedSchema, optionsSchema);
117115

@@ -159,4 +157,24 @@ public void ToOpenAiChatCompletionOptions_WithInvalidFormat_ThrowsAnException()
159157
var exception = Assert.Throws<DotPromptException>(() => promptFileMock.ToOpenAiChatCompletionOptions());
160158
Assert.Contains("The requested output format is not available", exception.Message);
161159
}
160+
161+
private static T GetInternalProperty<T>(object obj, string propertyName)
162+
{
163+
var property = obj.GetType().GetProperty(propertyName,
164+
BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance);
165+
166+
if (property == null)
167+
{
168+
throw new InvalidOperationException($"Property '{propertyName}' not found on type '{obj.GetType().Name}'");
169+
}
170+
171+
var value = property.GetValue(obj);
172+
173+
if (value is T result)
174+
{
175+
return result;
176+
}
177+
178+
throw new InvalidOperationException($"Property '{propertyName}' is not of expected type '{typeof(T).Name}'");
179+
}
162180
}

DotPrompt.Tests/PromptFileTests.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public void FromFile_BasicPrompt_ProducesValidPromptFile()
2121
};
2222

2323
Assert.Equal("basic", promptFile.Name);
24+
Assert.Equal(1, promptFile.Version);
2425

2526
Assert.NotNull(promptFile.Model);
2627
Assert.Equal("claude-3-5-sonnet-latest", promptFile.Model);
@@ -213,6 +214,30 @@ public void FromStream_WithMissingModelName_IsPersistedAsNullValue()
213214
Assert.Null(promptFile.Model);
214215
}
215216

217+
[Theory]
218+
[InlineData(int.MinValue, true)]
219+
[InlineData(-1, true)]
220+
[InlineData(0, false)]
221+
[InlineData(1, false)]
222+
public void FromStream_WithInvalidVersion_ThrowsAnException(int version, bool throwsException)
223+
{
224+
var content = $"name: test\nversion: {version}\nprompts:\n system: System prompt\n user: User prompt";
225+
using var ms = new MemoryStream(Encoding.UTF8.GetBytes(content));
226+
ms.Seek(0, SeekOrigin.Begin);
227+
228+
var act = () => PromptFile.FromStream("test", ms);
229+
230+
if (throwsException)
231+
{
232+
var exception = Assert.Throws<DotPromptException>(act);
233+
Assert.Contains("The version of the prompt file cannot be negative", exception.Message);
234+
}
235+
else
236+
{
237+
act();
238+
}
239+
}
240+
216241
[Fact]
217242
public void GenerateUserPrompt_UsingDefaults_CorrectlyGeneratesPromptFromTemplate()
218243
{
@@ -368,7 +393,7 @@ public void ToStream_WithValidInput_ProducesExpectedContent()
368393
}
369394
};
370395

371-
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";
396+
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";
372397

373398
var ms = new MemoryStream();
374399
promptFile.ToStream(ms);

DotPrompt.Tests/PromptManagerTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ public void PromptManager_WithPathSpecified_LoadsPromptsFromSpecifiedLocation()
4141
Assert.Contains("basic", actualPrompts);
4242
Assert.Contains("example-with-name", actualPrompts);
4343
}
44+
45+
[Fact]
46+
public void PromptManager_ListPromptFileNamesWithVersions_ReturnsListOfPromptFileNamesAndVersions()
47+
{
48+
var manager = new PromptManager();
49+
50+
var expectedPrompts = new List<string> { "basic:1", "example-with-name:1" };
51+
var actualPrompts = manager.ListPromptFileNamesWithVersions().ToList();
52+
53+
Assert.Equal(expectedPrompts.Count, actualPrompts.Count);
54+
foreach (var expectedPrompt in expectedPrompts)
55+
{
56+
Assert.Contains(expectedPrompt, actualPrompts);
57+
}
58+
}
4459

4560
[Fact]
4661
public void PromptManager_WithDuplicateNames_ThrowsException()
@@ -53,6 +68,54 @@ public void PromptManager_WithDuplicateNames_ThrowsException()
5368
Assert.Contains("a duplicate exists", exception.Message);
5469
}
5570

71+
[Fact]
72+
public void PromptManager_WithDifferentVersions_LoadsSuccessfully()
73+
{
74+
var manager = new PromptManager("multiple-version-prompts");
75+
76+
var expectedPrompts = new List<string> { "basic:1", "basic:2" };
77+
var actualPrompts = manager.ListPromptFileNamesWithVersions().ToList();
78+
79+
Assert.Equal(expectedPrompts.Count, actualPrompts.Count);
80+
foreach (var expectedPrompt in expectedPrompts)
81+
{
82+
Assert.Contains(expectedPrompt, actualPrompts);
83+
}
84+
}
85+
86+
[Fact]
87+
public void PromptManager_WhenRequestedByName_ReturnsLatestVersion()
88+
{
89+
var manager = new PromptManager("multiple-version-prompts");
90+
91+
var expectedPrompt = PromptFile.FromFile("multiple-version-prompts/basic-new.prompt");
92+
var actualPrompt = manager.GetPromptFile("basic");
93+
94+
Assert.Equivalent(expectedPrompt, actualPrompt, strict: true);
95+
}
96+
97+
[Fact]
98+
public void PromptManager_WhenRequestedByNameAndVersion_ReturnsCorrectVersion()
99+
{
100+
var manager = new PromptManager("multiple-version-prompts");
101+
102+
var expectedPrompt = PromptFile.FromFile("multiple-version-prompts/basic.prompt");
103+
var actualPrompt = manager.GetPromptFile("basic", 1);
104+
105+
Assert.Equivalent(expectedPrompt, actualPrompt, strict: true);
106+
}
107+
108+
[Fact]
109+
public void PromptManager_WhenRequestedByNameAndInvalidVersion_ThrowsException()
110+
{
111+
var manager = new PromptManager("multiple-version-prompts");
112+
113+
var act = () => manager.GetPromptFile("basic", 3);
114+
115+
var exception = Assert.Throws<DotPromptException>(act);
116+
Assert.Equal("No prompt file with that name and version has been loaded", exception.Message);
117+
}
118+
56119
[Fact]
57120
public void GetPromptFile_WhenRequestedWithValidName_LoadsExpectedPromptFile()
58121
{
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: basic
2+
version: 2
3+
config:
4+
outputFormat: text
5+
temperature: 0.9
6+
maxTokens: 500
7+
input:
8+
parameters:
9+
country: string
10+
style?: string
11+
default:
12+
country: Malta
13+
prompts:
14+
system: |
15+
You are a helpful AI assistant that enjoys making capybara related puns. You should work as many into your response as possible
16+
user: |
17+
I am looking at going on holiday to {{ country }} and would like to know more about it, what can you tell me?
18+
{% if style -%}
19+
Can you answer in the style of a {{ style }}
20+
{% endif -%}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: basic
2+
version: 1
3+
config:
4+
outputFormat: text
5+
temperature: 0.9
6+
maxTokens: 500
7+
input:
8+
parameters:
9+
country: string
10+
style?: string
11+
default:
12+
country: Malta
13+
prompts:
14+
system: |
15+
You are a helpful AI assistant that enjoys making penguin related puns. You should work as many into your response as possible
16+
user: |
17+
I am looking at going on holiday to {{ country }} and would like to know more about it, what can you tell me?
18+
{% if style -%}
19+
Can you answer in the style of a {{ style }}
20+
{% endif -%}

DotPrompt/DotPrompt.csproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121
</PropertyGroup>
2222

2323
<ItemGroup>
24-
<PackageReference Include="Fluid.Core" Version="2.23.0" />
25-
<PackageReference Include="JsonSchema.Net" Version="7.3.4" />
26-
<PackageReference Include="OpenAI" Version="2.1.0" />
24+
<PackageReference Include="Fluid.Core" Version="2.25.0" />
25+
<PackageReference Include="JsonSchema.Net" Version="7.4.0" />
26+
<PackageReference Include="OpenAI" Version="2.3.0" />
2727
<PackageReference Include="YamlDotNet" Version="16.3.0" />
2828
</ItemGroup>
2929

DotPrompt/PromptFile.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ public partial class PromptFile
2323
/// </summary>
2424
public required string Name { get; set; }
2525

26+
/// <summary>
27+
/// Gets, sets the version of the prompt file.
28+
/// </summary>
29+
public int Version { get; set; } = 1;
30+
2631
/// <summary>
2732
/// Gets, sets the name of the model (or deployment) the prompt should be executed using
2833
/// </summary>
@@ -113,6 +118,12 @@ public static PromptFile FromStream(string name, Stream inputStream)
113118
{
114119
promptFile.Name = name;
115120
}
121+
122+
// If the prompt version is negative, then throw an exception
123+
if (promptFile.Version < 0)
124+
{
125+
throw new DotPromptException("The version of the prompt file cannot be negative");
126+
}
116127

117128
// If the prompt output configuration is null then create a new one and set the output format. This is to handle
118129
// instances where the output format is slightly older and is set at the top level

DotPrompt/PromptManager.cs

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22

33
namespace DotPrompt;
44

5+
/// <summary>
6+
/// Identifies a .prompt file uniquely using its name and version
7+
/// </summary>
8+
/// <param name="Name">The name of the prompt file</param>
9+
/// <param name="Version">The prompt file version</param>
10+
public record PromptFileIdentifier(string Name, int Version);
11+
512
/// <summary>
613
/// Manages loading and accessing of .prompt files from a specified directory.
714
/// </summary>
815
public class PromptManager : IPromptManager
916
{
10-
private readonly ConcurrentDictionary<string, PromptFile> _promptFiles = new();
17+
private readonly ConcurrentDictionary<PromptFileIdentifier, PromptFile> _promptFiles = new();
1118

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

4047
/// <summary>
41-
/// Retrieves a <see cref="PromptFile"/> by its name
48+
/// Retrieves a <see cref="PromptFile"/> by its name. If multiple versions of the prompt file are found, the
49+
/// latest version is returned.
4250
/// </summary>
4351
/// <param name="name">The name of the prompt file to retrieve</param>
4452
/// <returns>The <see cref="PromptFile"/> with the specified name</returns>
4553
/// <exception cref="DotPromptException">Thrown when no prompt file with the specified name is found</exception>
4654
public PromptFile GetPromptFile(string name)
4755
{
48-
if (_promptFiles.TryGetValue(name, out var promptFile))
49-
{
50-
return promptFile;
51-
}
56+
var promptFilesWithName = _promptFiles
57+
.Where(kvp => kvp.Key.Name == name)
58+
.OrderByDescending(kvp => kvp.Key.Version)
59+
.ToList();
5260

53-
throw new DotPromptException("No prompt file with that name has been loaded");
61+
return promptFilesWithName.Count == 0
62+
? throw new DotPromptException("No prompt file with that name has been loaded")
63+
: promptFilesWithName[0].Value;
5464
}
5565

66+
/// <summary>
67+
/// Retrieves a <see cref="PromptFile"/> by its name and version.
68+
/// </summary>
69+
/// <param name="name">The name of the prompt file to retrieve</param>
70+
/// <param name="version">The version of the prompt file to retrieve</param>
71+
/// <returns>The <see cref="PromptFile"/> with the specified name and version</returns>
72+
/// <exception cref="DotPromptException">Thrown when no prompt file with the specified name and version is found</exception>
73+
public PromptFile GetPromptFile(string name, int version)
74+
{
75+
return _promptFiles.TryGetValue(new PromptFileIdentifier(name, version), out var promptFile)
76+
? promptFile
77+
: throw new DotPromptException("No prompt file with that name and version has been loaded");
78+
}
79+
5680
/// <summary>
5781
/// Lists the names of all loaded prompt files.
5882
/// </summary>
5983
/// <returns>An enumerable collection of prompt file names.</returns>
6084
public IEnumerable<string> ListPromptFileNames()
6185
{
62-
return _promptFiles.Keys.Select(k => k);
86+
return _promptFiles
87+
.DistinctBy(kvp => kvp.Key.Name)
88+
.OrderBy(kvp => kvp.Key.Name)
89+
.Select(kvp => $"{kvp.Key.Name}");
90+
}
91+
92+
/// <summary>
93+
/// Lists the names of all loaded prompts with their versions.
94+
/// </summary>
95+
/// <returns>An enumerable collection of prompt file names and versions.</returns>
96+
public IEnumerable<string> ListPromptFileNamesWithVersions()
97+
{
98+
return _promptFiles
99+
.OrderBy(kvp => kvp.Key.Name)
100+
.ThenByDescending(kvp => kvp.Key.Version)
101+
.Select(kvp => $"{kvp.Key.Name}:{kvp.Key.Version}");
63102
}
64103
}

0 commit comments

Comments
 (0)