mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-10 23:47:21 +00:00
Added the guided chat to the BeyondFacts Lab (#1177)
This commit is contained in:
1 parent
e77645f44c
commit
79e0c80031
55 files changed
+3481
-72
No files matched your search
@@ -436,6 +436,34 @@ public sealed class BeyondFactsLabStoreTests
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheProviderIsRememberedWithoutCountingAsAChange()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
|
||||
await store.SetProviderAsync(created.ProjectId, "provider-1");
|
||||
var loaded = await this.CreateStore().LoadAsync(created.ProjectId);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(created.ProviderId, Is.Empty, "A new preparation starts with the preselected provider.");
|
||||
Assert.That(loaded!.ProviderId, Is.EqualTo("provider-1"));
|
||||
Assert.That(loaded.ModifiedAtUtc, Is.EqualTo(created.ModifiedAtUtc));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APreparationWithoutAProviderIdIsNotOpened()
|
||||
{
|
||||
var projectId = Guid.NewGuid();
|
||||
await this.WriteProjectFileAsync(projectId, $$"""{ "schemaVersion": 1, "projectId": "{{projectId:D}}", "name": "Panel", "providerId": null }""");
|
||||
|
||||
var loaded = await this.CreateStore().LoadAsync(projectId);
|
||||
|
||||
Assert.That(loaded, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APreparationWithAnUnknownStepIsNotOpened()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
using AIStudio.Chat;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the BeyondFacts Lab stores the chats of the steps.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class BeyondFactsLabStoreThreadTests
|
||||
{
|
||||
private string dataDirectory = string.Empty;
|
||||
|
||||
private string RootDirectory => Path.Combine(this.dataDirectory, BeyondFactsLabStore.ROOT_DIRECTORY_NAME);
|
||||
|
||||
[SetUp]
|
||||
public void CreateDataDirectory()
|
||||
{
|
||||
this.dataDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-beyondfacts-lab-threads-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(this.dataDirectory);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void DeleteDataDirectory()
|
||||
{
|
||||
if (Directory.Exists(this.dataDirectory))
|
||||
Directory.Delete(this.dataDirectory, true);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AStepWithoutAChatHasNone()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
|
||||
var thread = await store.LoadThreadAsync(new BflThreadLocation(created.ProjectId, BflStep.FRAMEWORK));
|
||||
|
||||
Assert.That(thread, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AStoredChatIsLoadedWithItsMessages()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var location = new BflThreadLocation(created.ProjectId, BflStep.KNOWLEDGE);
|
||||
var thread = WithMessage(BflThreadFactory.Create(location), "Worauf beruht meine Expertise?");
|
||||
|
||||
var changed = await store.SaveThreadAsync(location, thread);
|
||||
var loaded = await this.CreateStore().LoadThreadAsync(location);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(loaded, Is.Not.Null);
|
||||
Assert.That(loaded!.ChatId, Is.EqualTo(location.ChatId));
|
||||
Assert.That(loaded.Blocks, Has.Count.EqualTo(1));
|
||||
Assert.That(((ContentText)loaded.Blocks[0].Content!).Text, Is.EqualTo("Worauf beruht meine Expertise?"));
|
||||
Assert.That(changed.ModifiedAtUtc, Is.GreaterThan(created.ModifiedAtUtc), "A conversation is work on the preparation.");
|
||||
Assert.That(File.Exists(Path.Combine(this.RootDirectory, created.ProjectId.ToString("D"), "steps", "02-knowledge", "thread.json")), Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AChatOfAnotherStepIsNotStored()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var thread = BflThreadFactory.Create(new BflThreadLocation(created.ProjectId, BflStep.STRATEGY));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(async () => await store.SaveThreadAsync(new BflThreadLocation(created.ProjectId, BflStep.FRAMEWORK), thread));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AChatOfADeletedPreparationIsNotStored()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var location = new BflThreadLocation(created.ProjectId, BflStep.FRAMEWORK);
|
||||
await store.DeleteAsync(created.ProjectId);
|
||||
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(async () => await store.SaveThreadAsync(location, BflThreadFactory.Create(location)));
|
||||
|
||||
Assert.That(Directory.Exists(Path.Combine(this.RootDirectory, created.ProjectId.ToString("D"))), Is.False, "An answer which ends after the deletion must not bring the preparation back.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AChatCopiedIntoAnotherStepIsNotOpened()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var strategy = new BflThreadLocation(created.ProjectId, BflStep.STRATEGY);
|
||||
var framework = new BflThreadLocation(created.ProjectId, BflStep.FRAMEWORK);
|
||||
await store.SaveThreadAsync(strategy, BflThreadFactory.Create(strategy));
|
||||
var stepsDirectory = Path.Combine(this.RootDirectory, created.ProjectId.ToString("D"), "steps");
|
||||
Directory.CreateDirectory(Path.Combine(stepsDirectory, "01-framework"));
|
||||
File.Copy(Path.Combine(stepsDirectory, "05-strategy", "thread.json"), Path.Combine(stepsDirectory, "01-framework", "thread.json"));
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.LoadThreadAsync(framework));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.SaveThreadAsync(framework, BflThreadFactory.Create(framework)));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ADamagedChatIsNeitherOpenedNorOverwritten()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var location = new BflThreadLocation(created.ProjectId, BflStep.FRAMEWORK);
|
||||
var path = Path.Combine(this.RootDirectory, created.ProjectId.ToString("D"), "steps", "01-framework", "thread.json");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
await File.WriteAllTextAsync(path, "{ damaged");
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.LoadThreadAsync(location));
|
||||
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.SaveThreadAsync(location, BflThreadFactory.Create(location)));
|
||||
Assert.That(await File.ReadAllTextAsync(path), Is.EqualTo("{ damaged"));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StartingOverDeletesTheChat()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var created = await store.CreateAsync("Panel");
|
||||
var location = new BflThreadLocation(created.ProjectId, BflStep.FRAMEWORK);
|
||||
await store.SaveThreadAsync(location, WithMessage(BflThreadFactory.Create(location), "Hallo"));
|
||||
|
||||
await store.DeleteThreadAsync(location);
|
||||
|
||||
Assert.That(await store.LoadThreadAsync(location), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EveryChatOnTheDiskIsFoundByItsId()
|
||||
{
|
||||
var store = this.CreateStore();
|
||||
var first = await store.CreateAsync("Panel");
|
||||
var second = await store.CreateAsync("Interview");
|
||||
var firstStrategy = new BflThreadLocation(first.ProjectId, BflStep.STRATEGY);
|
||||
var secondFramework = new BflThreadLocation(second.ProjectId, BflStep.FRAMEWORK);
|
||||
await store.SaveThreadAsync(firstStrategy, BflThreadFactory.Create(firstStrategy));
|
||||
await store.SaveThreadAsync(secondFramework, BflThreadFactory.Create(secondFramework));
|
||||
|
||||
// A preparation this version cannot open keeps its chats all the same:
|
||||
await File.WriteAllTextAsync(Path.Combine(this.RootDirectory, second.ProjectId.ToString("D"), "project.json"), "{ damaged");
|
||||
|
||||
var locations = this.CreateStore().ReadAllThreadLocations();
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(locations, Has.Count.EqualTo(2));
|
||||
Assert.That(locations[firstStrategy.ChatId], Is.EqualTo(firstStrategy));
|
||||
Assert.That(locations[secondFramework.ChatId], Is.EqualTo(secondFramework));
|
||||
Assert.That(store.GetThreadWrittenAt(firstStrategy), Is.Not.Null);
|
||||
Assert.That(store.GetThreadWrittenAt(new BflThreadLocation(first.ProjectId, BflStep.FRAMEWORK)), Is.Null);
|
||||
}
|
||||
}
|
||||
|
||||
private static ChatThread WithMessage(ChatThread thread, string text)
|
||||
{
|
||||
thread.Blocks.Add(new ContentBlock
|
||||
{
|
||||
Time = DateTimeOffset.Now,
|
||||
ContentType = ContentType.TEXT,
|
||||
Role = ChatRole.USER,
|
||||
Content = new ContentText { Text = text }
|
||||
});
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
private BeyondFactsLabStore CreateStore() => new(NullLogger<BeyondFactsLabStore>.Instance, new BflStorageOptions { DataDirectory = this.dataDirectory });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
using AIStudio.Models;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which models the BeyondFacts Lab accepts for its chats.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class BflModelRequirementsTests
|
||||
{
|
||||
[Test]
|
||||
public void AModelWithoutAStatementAboutItsWindowIsAccepted()
|
||||
{
|
||||
Assert.That(BflModelRequirements.HasEnoughContext(ContextWindow.UNKNOWN), Is.True);
|
||||
}
|
||||
|
||||
[TestCase(8_192, false)]
|
||||
[TestCase(BflModelRequirements.MINIMUM_CONTEXT_TOKENS - 1, false)]
|
||||
[TestCase(BflModelRequirements.MINIMUM_CONTEXT_TOKENS, true)]
|
||||
[TestCase(128_000, true)]
|
||||
public void AKnownWindowNeedsTheMinimum(int defaultTokens, bool expected)
|
||||
{
|
||||
Assert.That(BflModelRequirements.HasEnoughContext(ContextWindow.Of(defaultTokens)), Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AWindowWhichOnlyAnOperatorCouldRaiseIsNotEnough()
|
||||
{
|
||||
Assert.That(BflModelRequirements.HasEnoughContext(ContextWindow.Of(16_384, 131_072)), Is.False, "What a model reads as it ships counts, not what it might read.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the BeyondFacts Lab puts together the system prompt of a step.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class BflPromptComposerTests
|
||||
{
|
||||
private const string CORE = "Sprache: {{RESPONSE_LANGUAGE}}\n{{ADDRESS_FORM}}\n{{KNOWLEDGE}}\n{{STEP}}\n{{DETAILS}}\n{{MISSING_DETAILS}}";
|
||||
|
||||
private static readonly BflPromptParts PARTS = new(CORE, "Leitfaden", "Schritt", "Anrede");
|
||||
|
||||
[Test]
|
||||
public void APlaceholderWithoutAValueIsAnError()
|
||||
{
|
||||
var exception = Assert.Throws<FormatException>(() => BflPromptTemplate.Fill("Hallo {{UNKNOWN}}", new Dictionary<string, string>()));
|
||||
|
||||
Assert.That(exception!.Message, Does.Contain("UNKNOWN"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AMisspelledPlaceholderIsAnError()
|
||||
{
|
||||
Assert.Throws<FormatException>(() => BflPromptTemplate.Fill("Hallo {{ step }}", new Dictionary<string, string> { [BflPromptTemplate.STEP] = "Schritt" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AValueIsInsertedAsItIs()
|
||||
{
|
||||
var prompt = BflPromptTemplate.Fill("{{STEP}}", new Dictionary<string, string> { [BflPromptTemplate.STEP] = "Die Person schrieb {{KNOWLEDGE}}." });
|
||||
|
||||
Assert.That(prompt, Is.EqualTo("Die Person schrieb {{KNOWLEDGE}}."), "What the person entered must never be read as a placeholder.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ThePromptOfEveryStepFillsEveryPlaceholder()
|
||||
{
|
||||
foreach (var step in BflStep.All)
|
||||
{
|
||||
var parts = await BflPromptLibrary.LoadPartsAsync(step, AddressForm.FORMAL);
|
||||
var prompt = BflPromptComposer.Compose(parts, step, new Dictionary<BflStep, BflStepFormData>(), "Deutsch");
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(prompt, Does.Not.Contain("{{"), step.ToString());
|
||||
Assert.That(prompt, Does.Contain(parts.Step.Trim()), step.ToString());
|
||||
Assert.That(prompt, Does.Contain(parts.Knowledge.Trim()[..200]), step.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheFormOfAddressDecidesTheInstruction()
|
||||
{
|
||||
var formal = BflPromptComposer.Compose(await BflPromptLibrary.LoadPartsAsync(BflStep.FRAMEWORK, AddressForm.FORMAL), BflStep.FRAMEWORK, new Dictionary<BflStep, BflStepFormData>(), "Deutsch");
|
||||
var informal = BflPromptComposer.Compose(await BflPromptLibrary.LoadPartsAsync(BflStep.FRAMEWORK, AddressForm.INFORMAL), BflStep.FRAMEWORK, new Dictionary<BflStep, BflStepFormData>(), "Deutsch");
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(formal, Does.Contain("mit „Sie“"));
|
||||
Assert.That(formal, Does.Not.Contain("mit „du“"));
|
||||
Assert.That(informal, Does.Contain("mit „du“"));
|
||||
Assert.That(informal, Does.Not.Contain("mit „Sie“"));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLanguageOfTheAnswersIsNamed()
|
||||
{
|
||||
var prompt = BflPromptComposer.Compose(PARTS, BflStep.FRAMEWORK, new Dictionary<BflStep, BflStepFormData>(), "English (United States)");
|
||||
|
||||
Assert.That(prompt, Does.StartWith("Sprache: English (United States)\n"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheMissingKeyDetailsAreListed()
|
||||
{
|
||||
var prompt = BflPromptComposer.Compose(PARTS, BflStep.FRAMEWORK, new Dictionary<BflStep, BflStepFormData>(), "Deutsch");
|
||||
|
||||
foreach (var field in BflFieldCatalog.FIELDS.Where(field => field.IsKeyDetail))
|
||||
Assert.That(prompt, Does.Contain(field.Label), field.Id);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NothingIsMissingWhenEveryKeyDetailIsThere()
|
||||
{
|
||||
var forms = BflStep.All.ToDictionary(step => step, _ => new BflStepFormData());
|
||||
foreach (var field in BflFieldCatalog.FIELDS.Where(field => field.IsKeyDetail))
|
||||
forms[field.Step].Set(field.Id, "vorhanden");
|
||||
|
||||
var prompt = BflPromptComposer.Compose(PARTS, BflStep.FRAMEWORK, forms, "Deutsch");
|
||||
|
||||
Assert.That(prompt, Does.EndWith("Es fehlen keine wichtigen Angaben."));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheDetailsShowTheLabelsAndTheChosenOptions()
|
||||
{
|
||||
var framework = new BflStepFormData();
|
||||
framework.Set(BflFieldCatalog.FORMAT, "Podiumsdiskussion");
|
||||
var goals = new BflStepFormData();
|
||||
var directions = BflFieldCatalog.FIELDS.Single(field => field.Id == BflFieldCatalog.GOAL_DIRECTIONS);
|
||||
goals.SetChoices(directions, [BflFieldCatalog.GOAL_INFORM, BflFieldCatalog.GOAL_SHOW_OPTIONS]);
|
||||
var forms = new Dictionary<BflStep, BflStepFormData> { [BflStep.FRAMEWORK] = framework, [BflStep.GOALS_AND_ROLE] = goals };
|
||||
|
||||
var prompt = BflPromptComposer.Compose(PARTS, BflStep.FRAMEWORK, forms, "Deutsch");
|
||||
var inform = directions.Options.Single(option => option.Id == BflFieldCatalog.GOAL_INFORM).Label;
|
||||
var showOptions = directions.Options.Single(option => option.Id == BflFieldCatalog.GOAL_SHOW_OPTIONS).Label;
|
||||
var format = BflFieldCatalog.FIELDS.Single(field => field.Id == BflFieldCatalog.FORMAT).Label;
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(prompt, Does.Contain($"- {format}: Podiumsdiskussion"));
|
||||
Assert.That(prompt, Does.Contain($"- {directions.Label}: {inform}, {showOptions}"));
|
||||
Assert.That(prompt, Does.Contain($"## {BflStep.FRAMEWORK.Title} (dieser Schritt)"));
|
||||
Assert.That(prompt, Does.Not.Contain(BflFieldCatalog.GOAL_INFORM), "The model reads the labels, not the IDs.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALongTextKeepsItsLines()
|
||||
{
|
||||
var knowledge = new BflStepFormData();
|
||||
knowledge.Set(BflFieldCatalog.CORE_THESES, "Erste These\r\nZweite These");
|
||||
|
||||
var prompt = BflPromptComposer.Compose(PARTS, BflStep.KNOWLEDGE, new Dictionary<BflStep, BflStepFormData> { [BflStep.KNOWLEDGE] = knowledge }, "Deutsch");
|
||||
|
||||
Assert.That(prompt, Does.Contain(": Erste These\n Zweite These"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APersonalDetailReachesOnlyTheChatOfItsOwnStep()
|
||||
{
|
||||
var values = new BflStepFormData();
|
||||
values.Set(BflFieldCatalog.PERSONAL_CONCERN, "VERTRAULICH-4711");
|
||||
values.Set(BflFieldCatalog.VALUES_AND_CONCERNS, "Offenheit");
|
||||
var forms = new Dictionary<BflStep, BflStepFormData> { [BflStep.VALUES_AND_PERSPECTIVES] = values };
|
||||
|
||||
foreach (var step in BflStep.All)
|
||||
{
|
||||
var prompt = BflPromptComposer.Compose(PARTS, step, forms, "Deutsch");
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(prompt.Contains("VERTRAULICH-4711", StringComparison.Ordinal), Is.EqualTo(step is BflStep.VALUES_AND_PERSPECTIVES), step.ToString());
|
||||
Assert.That(prompt, Does.Contain("Offenheit"), "Only what the person confided stays in its step.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheSameFormsGiveTheSamePrompt()
|
||||
{
|
||||
var framework = new BflStepFormData();
|
||||
framework.Set(BflFieldCatalog.AUDIENCE, "Öffentlichkeit");
|
||||
framework.Set(BflFieldCatalog.FORMAT, "Interview");
|
||||
var strategy = new BflStepFormData();
|
||||
strategy.Set(BflFieldCatalog.COMMUNICATION_WORRIES, "Zeitdruck");
|
||||
|
||||
var first = BflPromptComposer.Compose(PARTS, BflStep.STRATEGY, new Dictionary<BflStep, BflStepFormData> { [BflStep.FRAMEWORK] = framework, [BflStep.STRATEGY] = strategy }, "Deutsch");
|
||||
var second = BflPromptComposer.Compose(PARTS, BflStep.STRATEGY, new Dictionary<BflStep, BflStepFormData> { [BflStep.STRATEGY] = strategy, [BflStep.FRAMEWORK] = framework }, "Deutsch");
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(second, Is.EqualTo(first));
|
||||
Assert.That(first, Does.Not.Contain("\r"));
|
||||
Assert.That(first.IndexOf("Interview", StringComparison.Ordinal), Is.LessThan(first.IndexOf("Öffentlichkeit", StringComparison.Ordinal)), "The details follow the order of the forms.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that AI Studio comes with every prompt of the BeyondFacts Lab, and that they stay small enough.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The tests run a development build, which reads the prompts from its output folder: the project file copies every
|
||||
/// embedded file there as well.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class BflPromptLibraryTests
|
||||
{
|
||||
/// <summary>
|
||||
/// How many characters the fixed part of the longest system prompt may have: the core, the guide, the longest step,
|
||||
/// and the form of address.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// German takes about four characters per token, so this is about 22,000 tokens. The lab accepts models with a
|
||||
/// context of 32,000 tokens; the rest is left for the details of the person and the conversation. Raising this
|
||||
/// limit means raising that minimum, too.
|
||||
/// </remarks>
|
||||
private const int MAX_FIXED_PROMPT_CHARACTERS = 90_000;
|
||||
|
||||
[Test]
|
||||
public async Task EveryPromptComesWithTheApp()
|
||||
{
|
||||
foreach (var path in BflPromptLibrary.AllPaths)
|
||||
Assert.That(await BflPromptLibrary.ReadAsync(path), Is.Not.Empty, path);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryStepHasAPromptOfItsOwn()
|
||||
{
|
||||
var paths = BflStep.All.Select(BflPromptLibrary.StepPath).ToList();
|
||||
|
||||
Assert.That(paths, Is.Unique);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BothFormsOfAddressHaveAnInstructionOfTheirOwn()
|
||||
{
|
||||
Assert.That(BflPromptLibrary.AddressPath(AddressForm.INFORMAL), Is.Not.EqualTo(BflPromptLibrary.AddressPath(AddressForm.FORMAL)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AMissingPromptIsAnError()
|
||||
{
|
||||
await Assert.ThrowsAsync<FileNotFoundException>(async () => await BflPromptLibrary.ReadAsync("Assistants/BeyondFactsLab/Prompts/does-not-exist.md"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheFixedPartOfTheLongestPromptLeavesRoomForTheConversation()
|
||||
{
|
||||
var core = await BflPromptLibrary.ReadAsync(BflPromptLibrary.CORE_PATH);
|
||||
var knowledge = await BflPromptLibrary.ReadAsync(BflPromptLibrary.KNOWLEDGE_PATH);
|
||||
var longestStep = 0;
|
||||
foreach (var step in BflStep.All)
|
||||
longestStep = Math.Max(longestStep, (await BflPromptLibrary.ReadAsync(BflPromptLibrary.StepPath(step))).Length);
|
||||
|
||||
var longestAddress = 0;
|
||||
foreach (var addressForm in Enum.GetValues<AddressForm>())
|
||||
longestAddress = Math.Max(longestAddress, (await BflPromptLibrary.ReadAsync(BflPromptLibrary.AddressPath(addressForm))).Length);
|
||||
|
||||
Assert.That(core.Length + knowledge.Length + longestStep + longestAddress, Is.LessThanOrEqualTo(MAX_FIXED_PROMPT_CHARACTERS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the IDs of the chats of the BeyondFacts Lab and how the lab sets them up.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class BflThreadTests
|
||||
{
|
||||
private static readonly Guid PROJECT_ID = Guid.Parse("6f1c2b0e-3a51-4c3e-9d2f-0b7a4e5c8d91");
|
||||
|
||||
[Test]
|
||||
public void TheIdOfAChatFollowsFromItsPlace()
|
||||
{
|
||||
// Computed independently, from SHA-256 over "beyondfacts-lab/thread/<project>/05-strategy". A stored chat keeps
|
||||
// its ID, so this value never changes:
|
||||
var expected = Guid.Parse("a6e33c75-66c3-8ac8-bae2-8b6a90fb6f19");
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(BflThreadId.Of(PROJECT_ID, BflStep.STRATEGY), Is.EqualTo(expected));
|
||||
Assert.That(new BflThreadLocation(PROJECT_ID, BflStep.STRATEGY).ChatId, Is.EqualTo(expected));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryChatHasAnIdOfItsOwn()
|
||||
{
|
||||
var otherProject = Guid.NewGuid();
|
||||
var ids = BflStep.All.SelectMany(step => new[] { BflThreadId.Of(PROJECT_ID, step), BflThreadId.Of(otherProject, step) }).ToList();
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(ids, Is.Unique);
|
||||
Assert.That(ids, Has.None.EqualTo(Guid.Empty));
|
||||
Assert.That(ids, Has.None.EqualTo(PROJECT_ID));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheIdIsAVersion8Uuid()
|
||||
{
|
||||
var id = BflThreadId.Of(PROJECT_ID, BflStep.FRAMEWORK);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(id.Version, Is.EqualTo(8));
|
||||
Assert.That(id.Variant & 0xC, Is.EqualTo(0x8), "The variant of RFC 9562.");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ANewChatHasNothingButItsPlace()
|
||||
{
|
||||
var location = new BflThreadLocation(PROJECT_ID, BflStep.KNOWLEDGE);
|
||||
|
||||
var thread = BflThreadFactory.Create(location);
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(thread.ChatId, Is.EqualTo(location.ChatId));
|
||||
Assert.That(thread.Blocks, Is.Empty);
|
||||
Assert.That(thread.SelectedProfile, Is.EqualTo(Profile.NO_PROFILE.Id));
|
||||
Assert.That(thread.SelectedChatTemplate, Is.EqualTo(ChatTemplate.NO_CHAT_TEMPLATE.Id));
|
||||
Assert.That(thread.SelectedToolIds, Is.Empty);
|
||||
Assert.That(thread.DataSourceOptions.DisableDataSources, Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AChatIsReadiedAsAChatOfTheLabWithoutToolsOrDataSources()
|
||||
{
|
||||
var thread = BflThreadFactory.Create(new BflThreadLocation(PROJECT_ID, BflStep.KNOWLEDGE));
|
||||
thread.DataSourceOptions.DisableDataSources = false;
|
||||
thread.RuntimeSelectedToolIds = ["web_search"];
|
||||
var provider = new AIStudio.Settings.Provider(1, "provider-1", "Test", LLMProviders.SELF_HOSTED, new Model("model", null));
|
||||
|
||||
BflThreadFactory.PrepareForRequest(thread, provider, "Prompt");
|
||||
|
||||
using (Assert.EnterMultipleScope())
|
||||
{
|
||||
Assert.That(thread.SystemPrompt, Is.EqualTo("Prompt"));
|
||||
Assert.That(thread.SelectedProvider, Is.EqualTo("provider-1"));
|
||||
Assert.That(thread.RuntimeComponent, Is.EqualTo(AIStudio.Tools.Components.BEYOND_FACTS_LAB_ASSISTANT));
|
||||
Assert.That(thread.RuntimeToolsAreAssistantManaged, Is.True);
|
||||
Assert.That(thread.RuntimeSelectedToolIds, Is.Empty);
|
||||
Assert.That(thread.DataSourceOptions.DisableDataSources, Is.True);
|
||||
Assert.That(thread.SelectedProfile, Is.EqualTo(Profile.NO_PROFILE.Id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Models;
|
||||
|
||||
namespace AIStudio.Tests.BeyondFactsLab;
|
||||
|
||||
/// <summary>
|
||||
/// Checks when the BeyondFacts Lab warns about a full context, and when it stops sending.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class BflTokenBudgetTests
|
||||
{
|
||||
private const int WINDOW = 32_000;
|
||||
|
||||
[Test]
|
||||
public void ACountWhichFailedTellsNothing()
|
||||
{
|
||||
Assert.That(BflTokenBudget.Of(ConversationTokens.UNAVAILABLE), Is.EqualTo(BflTokenBudgetState.UNKNOWN));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AModelWithoutAStatementAboutItsWindowTellsNothing()
|
||||
{
|
||||
var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = 500_000, Window = ContextWindow.UNKNOWN };
|
||||
|
||||
Assert.That(BflTokenBudget.Of(tokens), Is.EqualTo(BflTokenBudgetState.UNKNOWN), "The lab cannot know when such a model is full, so it never stops sending.");
|
||||
}
|
||||
|
||||
[TestCase(0, BflTokenBudgetState.OK)]
|
||||
[TestCase(25_599, BflTokenBudgetState.OK)]
|
||||
[TestCase(25_600, BflTokenBudgetState.WARNING)]
|
||||
[TestCase(WINDOW - BflTokenBudget.RESERVE_TOKENS, BflTokenBudgetState.WARNING)]
|
||||
[TestCase(WINDOW - BflTokenBudget.RESERVE_TOKENS + 1, BflTokenBudgetState.FULL)]
|
||||
[TestCase(WINDOW * 2, BflTokenBudgetState.FULL)]
|
||||
public void TheShareOfTheWindowDecides(int historyTokens, BflTokenBudgetState expected)
|
||||
{
|
||||
var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = historyTokens, Window = ContextWindow.Of(WINDOW) };
|
||||
|
||||
Assert.That(BflTokenBudget.Of(tokens), Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheMessageBeingWrittenCountsAsWell()
|
||||
{
|
||||
var tokens = new ConversationTokens { IsKnown = true, HistoryTokens = 20_000, DraftTokens = 6_000, Window = ContextWindow.Of(WINDOW) };
|
||||
|
||||
Assert.That(BflTokenBudget.Of(tokens), Is.EqualTo(BflTokenBudgetState.WARNING));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
|
||||
namespace AIStudio.Tests.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which keys send a message, for each shortcut a person can choose.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class SendBehaviorTests
|
||||
{
|
||||
[TestCase(SendBehavior.ENTER_IS_SENDING, "Enter", false, true)]
|
||||
[TestCase(SendBehavior.ENTER_IS_SENDING, "NumpadEnter", false, true)]
|
||||
[TestCase(SendBehavior.ENTER_IS_SENDING, "Enter", true, false)]
|
||||
[TestCase(SendBehavior.MODIFER_ENTER_IS_SENDING, "Enter", false, false)]
|
||||
[TestCase(SendBehavior.MODIFER_ENTER_IS_SENDING, "Enter", true, true)]
|
||||
[TestCase(SendBehavior.MODIFER_ENTER_IS_SENDING, "NumpadEnter", true, true)]
|
||||
[TestCase(SendBehavior.NO_KEY_IS_SENDING, "Enter", false, false)]
|
||||
[TestCase(SendBehavior.NO_KEY_IS_SENDING, "Enter", true, false)]
|
||||
public void EnterSendsAsChosen(SendBehavior behavior, string code, bool withShift, bool sends)
|
||||
{
|
||||
Assert.That(behavior.IsSendShortcut(new KeyboardEventArgs { Code = code, ShiftKey = withShift }), Is.EqualTo(sends));
|
||||
}
|
||||
|
||||
[TestCase(SendBehavior.ENTER_IS_SENDING)]
|
||||
[TestCase(SendBehavior.MODIFER_ENTER_IS_SENDING)]
|
||||
public void OtherKeysNeverSend(SendBehavior behavior)
|
||||
{
|
||||
Assert.That(behavior.IsSendShortcut(new KeyboardEventArgs { Code = "KeyA" }), Is.False);
|
||||
Assert.That(behavior.IsSendShortcut(new KeyboardEventArgs { Code = "KeyA", CtrlKey = true }), Is.False);
|
||||
}
|
||||
|
||||
[TestCase(true, false, false)]
|
||||
[TestCase(false, true, false)]
|
||||
[TestCase(false, false, true)]
|
||||
public void EveryModifierCounts(bool alt, bool ctrl, bool meta)
|
||||
{
|
||||
var keyEvent = new KeyboardEventArgs { Code = "Enter", AltKey = alt, CtrlKey = ctrl, MetaKey = meta };
|
||||
|
||||
Assert.That(SendBehavior.MODIFER_ENTER_IS_SENDING.IsSendShortcut(keyEvent), Is.True);
|
||||
Assert.That(SendBehavior.ENTER_IS_SENDING.IsSendShortcut(keyEvent), Is.False);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
using AIStudio.Assistants.BeyondFactsLab;
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tests.Tools.Databases;
|
||||
using AIStudio.Tools;
|
||||
using AIStudio.Tools.DataBackup;
|
||||
using AIStudio.Tools.Databases.ProvenanceStore;
|
||||
using AIStudio.Tools.RAG;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -72,7 +74,7 @@ public sealed class ProvenanceCleanupServiceTests
|
||||
var unchangedChat = await this.StoreUnchangedChatAsync();
|
||||
var unreferenced = await this.store.Client.StoreAsync(unchangedChat.ChatId, ProvenanceKind.ATTACHMENT, "of a message removed long ago", null, TOKEN);
|
||||
|
||||
await RunAsync(this.store, RestoreStateWith(report: null));
|
||||
await this.RunAsync(RestoreStateWith(report: null));
|
||||
|
||||
var entries = await this.store.Client.GetEntriesAsync([unreferenced!.Value], TOKEN);
|
||||
var lastCleanup = await this.store.Client.GetLastCleanupAsync(TOKEN);
|
||||
@@ -89,7 +91,7 @@ public sealed class ProvenanceCleanupServiceTests
|
||||
var unchangedChat = await this.StoreUnchangedChatAsync();
|
||||
var unreferenced = await this.store.Client.StoreAsync(unchangedChat.ChatId, ProvenanceKind.ATTACHMENT, "of a message the restored chat does not have", null, TOKEN);
|
||||
|
||||
await RunAsync(this.store, RestoreStateWith(new(DataRestoreOutcome.APPLIED, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty)));
|
||||
await this.RunAsync(RestoreStateWith(new(DataRestoreOutcome.APPLIED, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty)));
|
||||
|
||||
var entries = await this.store.Client.GetEntriesAsync([unreferenced!.Value], TOKEN);
|
||||
Assert.That(entries, Is.Empty, "The restored chat came from another moment than the store, so the time of the last cleanup tells nothing about it.");
|
||||
@@ -104,7 +106,7 @@ public sealed class ProvenanceCleanupServiceTests
|
||||
await File.WriteAllTextAsync(Path.Join(pendingDirectory, DataRestoreFormat.READY_FILE_NAME), string.Empty, TOKEN);
|
||||
await File.WriteAllTextAsync(Path.Join(pendingDirectory, DataRestoreFormat.JOURNAL_FILE_NAME), "{}", TOKEN);
|
||||
|
||||
await RunAsync(this.store, RestoreStateWith(report: null));
|
||||
await this.RunAsync(RestoreStateWith(report: null));
|
||||
|
||||
var entries = await this.store.Client.GetEntriesAsync([entry!.Value], TOKEN);
|
||||
var lastCleanup = await this.store.Client.GetLastCleanupAsync(TOKEN);
|
||||
@@ -115,6 +117,49 @@ public sealed class ProvenanceCleanupServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhatAChatOfTheLabReceivedSurvivesTheCleanup()
|
||||
{
|
||||
// The cleanup reads the chats of the lab without asking whether the lab is switched on; nothing in this test
|
||||
// switches it on:
|
||||
var labStore = this.CreateLabStore();
|
||||
var project = await labStore.CreateAsync("Panel");
|
||||
var location = new BflThreadLocation(project.ProjectId, BflStep.KNOWLEDGE);
|
||||
var referenced = await this.store.Client.StoreAsync(location.ChatId, ProvenanceKind.WEB_PAGE, "of a page the chat of the lab read", null, TOKEN);
|
||||
var unreferenced = await this.store.Client.StoreAsync(location.ChatId, ProvenanceKind.WEB_PAGE, "of an answer the person removed", null, TOKEN);
|
||||
var thread = BflThreadFactory.Create(location);
|
||||
thread.Blocks.Add(new ContentBlock
|
||||
{
|
||||
ContentType = ContentType.TEXT,
|
||||
Role = ChatRole.AI,
|
||||
Content = new ContentText { Text = "Laut der Seite …", SourcePassages = [new SourcePassage { SourceUrl = "https://example.org/", ProvenanceId = referenced }] }
|
||||
});
|
||||
await labStore.SaveThreadAsync(location, thread);
|
||||
|
||||
await this.RunAsync(RestoreStateWith(report: null));
|
||||
|
||||
var entries = await this.store.Client.GetEntriesAsync([referenced!.Value, unreferenced!.Value], TOKEN);
|
||||
Assert.That(entries.Keys, Is.EquivalentTo(new[] { referenced.Value }), "The chat of the lab is read like any other chat.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhatAChatOfADeletedPreparationReceivedIsRemoved()
|
||||
{
|
||||
var labStore = this.CreateLabStore();
|
||||
var project = await labStore.CreateAsync("Panel");
|
||||
var location = new BflThreadLocation(project.ProjectId, BflStep.KNOWLEDGE);
|
||||
await labStore.SaveThreadAsync(location, BflThreadFactory.Create(location));
|
||||
var entry = await this.store.Client.StoreAsync(location.ChatId, ProvenanceKind.WEB_PAGE, "of a page the chat of the lab read", null, TOKEN);
|
||||
await labStore.DeleteAsync(project.ProjectId);
|
||||
|
||||
await this.RunAsync(RestoreStateWith(report: null));
|
||||
|
||||
var entries = await this.store.Client.GetEntriesAsync([entry!.Value], TOKEN);
|
||||
Assert.That(entries, Is.Empty);
|
||||
}
|
||||
|
||||
private BeyondFactsLabStore CreateLabStore() => new(NullLogger<BeyondFactsLabStore>.Instance, new BflStorageOptions { DataDirectory = this.testDirectory });
|
||||
|
||||
/// <summary>
|
||||
/// Stores a chat whose file was written long before the last cleanup, which is noted as if it ran a minute ago.
|
||||
/// </summary>
|
||||
@@ -130,12 +175,12 @@ public sealed class ProvenanceCleanupServiceTests
|
||||
return chat;
|
||||
}
|
||||
|
||||
private static async Task RunAsync(TemporaryProvenanceStore store, DataRestoreService dataRestoreService)
|
||||
private async Task RunAsync(DataRestoreService dataRestoreService)
|
||||
{
|
||||
// Only entries stored before the start of the service may be removed, and the test stored them just now:
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(10), TOKEN);
|
||||
|
||||
using var service = new ProvenanceCleanupService(store.CreateClientProvider(), dataRestoreService, NullLogger<ProvenanceCleanupService>.Instance);
|
||||
using var service = new ProvenanceCleanupService(this.store.CreateClientProvider(), dataRestoreService, this.CreateLabStore(), NullLogger<ProvenanceCleanupService>.Instance);
|
||||
await service.StartAsync(TOKEN);
|
||||
await service.ExecuteTask!;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user