Added confirmed work states to the BeyondFacts Lab (#1182)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-10 11:27:51 +01:00
1 parent 6d0052ecdf
commit a9579dd282
39 files changed
+3160 -16

No files matched your search

@@ -0,0 +1,125 @@
using AIStudio.Assistants.BeyondFactsLab;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.BeyondFactsLab;
/// <summary>
/// Checks how the BeyondFacts Lab stores the work states of the steps.
/// </summary>
[TestFixture]
public sealed class BeyondFactsLabStoreWorkStateTests
{
private string dataDirectory = string.Empty;
[SetUp]
public void CreateDataDirectory()
{
this.dataDirectory = Path.Combine(Path.GetTempPath(), $"ai-studio-beyondfacts-lab-{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 AStepWithoutAWorkStateGetsAnEmptyOne()
{
var store = this.CreateStore();
var created = await store.CreateAsync("Panel");
var workState = await store.LoadWorkStateAsync(created.ProjectId, BflStep.KNOWLEDGE);
using (Assert.EnterMultipleScope())
{
Assert.That(workState.Draft, Is.Null);
Assert.That(workState.Confirmed, Is.Null);
Assert.That(workState.FieldSuggestions, Is.Empty);
}
}
[Test]
public async Task AWorkStateIsStoredWithItsConfirmationAndSuggestions()
{
var store = this.CreateStore();
var created = await store.CreateAsync("Panel");
var confirmedAtUtc = new DateTimeOffset(2026, 11, 10, 9, 0, 0, TimeSpan.Zero);
var workState = new BflWorkState { FieldSuggestions = { [BflFieldCatalog.CORE_THESES] = "These" } };
workState.Confirm(new Dictionary<string, string> { ["SUMMARY"] = "Stand" }, new Dictionary<BflStep, int> { [BflStep.FRAMEWORK] = 3 }, "fingerprint", confirmedAtUtc);
workState.Draft = new(StringComparer.Ordinal) { ["SUMMARY"] = "Entwurf" };
var changed = await store.SaveWorkStateAsync(created.ProjectId, BflStep.KNOWLEDGE, workState);
var loaded = await this.CreateStore().LoadWorkStateAsync(created.ProjectId, BflStep.KNOWLEDGE);
using (Assert.EnterMultipleScope())
{
Assert.That(changed.ModifiedAtUtc, Is.GreaterThanOrEqualTo(created.ModifiedAtUtc));
Assert.That(loaded.Draft, Is.EqualTo(workState.Draft));
Assert.That(loaded.LastRevision, Is.EqualTo(1));
Assert.That(loaded.Confirmed!.Revision, Is.EqualTo(1));
Assert.That(loaded.Confirmed.ConfirmedAtUtc, Is.EqualTo(confirmedAtUtc));
Assert.That(loaded.Confirmed.Sections, Is.EqualTo(workState.Confirmed!.Sections));
Assert.That(loaded.Confirmed.UpstreamRevisions, Is.EqualTo(workState.Confirmed.UpstreamRevisions));
Assert.That(loaded.Confirmed.DetailsFingerprint, Is.EqualTo("fingerprint"));
Assert.That(loaded.FieldSuggestions, Is.EqualTo(workState.FieldSuggestions));
}
}
[Test]
public async Task ADamagedWorkStateIsNeitherOpenedNorOverwritten()
{
const string DAMAGED = "{ \"draft\": ";
var store = this.CreateStore();
var created = await store.CreateAsync("Panel");
var path = await this.WriteWorkStateFileAsync(created.ProjectId, "02-knowledge", DAMAGED);
var workStates = await store.LoadWorkStatesAsync(created.ProjectId);
using (Assert.EnterMultipleScope())
{
Assert.That(workStates.ContainsKey(BflStep.KNOWLEDGE), Is.False);
Assert.That(workStates.ContainsKey(BflStep.FRAMEWORK), Is.True);
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.LoadWorkStateAsync(created.ProjectId, BflStep.KNOWLEDGE));
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.SaveWorkStateAsync(created.ProjectId, BflStep.KNOWLEDGE, new BflWorkState()));
Assert.That(await File.ReadAllTextAsync(path), Is.EqualTo(DAMAGED));
}
}
[Test]
public async Task AWorkStateOfANewerVersionIsNotOverwritten()
{
const string NEWER = """{ "schemaVersion": 2, "fieldSuggestions": {} }""";
var store = this.CreateStore();
var created = await store.CreateAsync("Panel");
var path = await this.WriteWorkStateFileAsync(created.ProjectId, "05-strategy", NEWER);
using (Assert.EnterMultipleScope())
{
await Assert.ThrowsAsync<InvalidDataException>(async () => await store.SaveWorkStateAsync(created.ProjectId, BflStep.STRATEGY, new BflWorkState()));
Assert.That(await File.ReadAllTextAsync(path), Is.EqualTo(NEWER));
}
}
[Test]
public async Task AWorkStateOfAMissingPreparationIsNotWritten()
{
var store = this.CreateStore();
await Assert.ThrowsAsync<FileNotFoundException>(async () => await store.SaveWorkStateAsync(Guid.NewGuid(), BflStep.FRAMEWORK, new BflWorkState()));
}
private async Task<string> WriteWorkStateFileAsync(Guid projectId, string stepFolder, string content)
{
var directory = Path.Combine(this.dataDirectory, BeyondFactsLabStore.ROOT_DIRECTORY_NAME, projectId.ToString("D"), "steps", stepFolder);
Directory.CreateDirectory(directory);
var path = Path.Combine(directory, "work-state.json");
await File.WriteAllTextAsync(path, content);
return path;
}
private BeyondFactsLabStore CreateStore() => new(NullLogger<BeyondFactsLabStore>.Instance, new BflStorageOptions { DataDirectory = this.dataDirectory });
}
@@ -0,0 +1,217 @@
using AIStudio.Assistants.BeyondFactsLab;
namespace AIStudio.Tests.BeyondFactsLab;
/// <summary>
/// Checks how the BeyondFacts Lab reads a work state which a model wrote as Markdown.
/// </summary>
/// <remarks>
/// The keys of the sections are stored in every work state, so they are pinned here as well.
/// </remarks>
[TestFixture]
public sealed class BflMarkdownSectionsTests
{
[Test]
public void TheSectionsKeepTheirKeys()
{
Assert.That(BflWorkStateSections.SECTIONS.Select(section => $"{section.Step}/{section.Key}"), Is.EqualTo(new[]
{
"FRAMEWORK/FORMAT", "FRAMEWORK/AUDIENCE", "FRAMEWORK/SPEAKING_TIME", "FRAMEWORK/PARTICIPANTS", "FRAMEWORK/CONFLICTS", "FRAMEWORK/OPEN_POINTS",
"KNOWLEDGE/ESTABLISHED", "KNOWLEDGE/UNCERTAINTIES", "KNOWLEDGE/WEAK_POINTS", "KNOWLEDGE/CONTROVERSIES", "KNOWLEDGE/EXPERTISE_LIMITS", "KNOWLEDGE/KEY_MESSAGES", "KNOWLEDGE/OPEN_POINTS",
"GOALS_AND_ROLE/GOAL", "GOALS_AND_ROLE/ROLE", "GOALS_AND_ROLE/REASONS", "GOALS_AND_ROLE/CONSEQUENCES", "GOALS_AND_ROLE/OPEN_POINTS",
"VALUES_AND_PERSPECTIVES/VALUES", "VALUES_AND_PERSPECTIVES/VALUE_CONFLICTS", "VALUES_AND_PERSPECTIVES/PERSONAL_INVOLVEMENT", "VALUES_AND_PERSPECTIVES/PERSPECTIVES", "VALUES_AND_PERSPECTIVES/CONSEQUENCES", "VALUES_AND_PERSPECTIVES/OPEN_POINTS",
"STRATEGY/KEY_MESSAGES", "STRATEGY/STRUCTURE", "STRATEGY/LANGUAGE", "STRATEGY/UNCERTAINTY", "STRATEGY/FRAMING", "STRATEGY/COUNTERARGUMENTS", "STRATEGY/WORRIES", "STRATEGY/OPEN_POINTS",
"TEST_SITUATION/STRENGTHS", "TEST_SITUATION/PRACTICE", "TEST_SITUATION/HARD_QUESTIONS", "TEST_SITUATION/POSITION", "TEST_SITUATION/OPEN_POINTS",
"RESULTS/CHANGED", "RESULTS/KEPT", "RESULTS/LEFT_OPEN", "RESULTS/NEXT_STEPS",
}));
}
[Test]
public void EveryStepHasSectionsWithUniqueKeysAndTitles()
{
foreach (var step in BflStep.All)
{
var sections = BflWorkStateSections.ForStep(step);
using (Assert.EnterMultipleScope())
{
Assert.That(sections, Is.Not.Empty, step.ToString());
Assert.That(sections.Select(section => section.Key), Is.Unique, step.ToString());
Assert.That(sections.Select(section => section.Title), Is.Unique, step.ToString());
Assert.That(sections.Select(section => section.Title), Has.None.EqualTo(string.Empty), step.ToString());
Assert.That(sections.Select(section => section.Description), Has.All.Not.Empty, step.ToString());
}
}
}
[Test]
public void OnlyThePersonalInvolvementIsPersonal()
{
Assert.That(BflWorkStateSections.SECTIONS.Where(section => section.IsPersonalDisclosure).Select(section => section.Key), Is.EqualTo(new[] { "PERSONAL_INVOLVEMENT" }));
}
[TestCase("## [GOAL] Ziel", TestName = "AHeadingOfTheSecondLevelIsASection")]
[TestCase("### [GOAL] Ziel", TestName = "AHeadingOfAnotherLevelIsASection")]
[TestCase("##[GOAL]", TestName = "AHeadingWithoutSpaceAndTitleIsASection")]
[TestCase("## **[GOAL] Ziel**", TestName = "ABoldHeadingIsASection")]
[TestCase("## [ goal ] Ziel", TestName = "TheKeyIgnoresCaseAndSpaces")]
[TestCase(" ## [GOAL] Ziel", TestName = "AnIndentedHeadingIsASection")]
public void TheHeadingOfASectionMayVary(string heading)
{
var answer = Read($"{heading}\nWirksam informieren.\n\n## [ROLE]\nEhrliche Maklerin\n## [REASONS]\n## [CONSEQUENCES]\nKurz fassen.\n## [OPEN_POINTS]\n");
using (Assert.EnterMultipleScope())
{
Assert.That(answer.Problems, Is.Empty);
Assert.That(answer.Sections["GOAL"], Is.EqualTo("Wirksam informieren."));
Assert.That(answer.Sections["ROLE"], Is.EqualTo("Ehrliche Maklerin"));
Assert.That(answer.Sections["REASONS"], Is.Empty);
Assert.That(answer.Sections["OPEN_POINTS"], Is.Empty);
}
}
[Test]
public void TextBeforeTheFirstHeadingAndACodeBlockAroundTheAnswerAreLeftOut()
{
var answer = Read("Hier ist der Arbeitsstand:\n```markdown\n## [GOAL] Ziel\nInformieren\n- Punkt eins\n- Punkt zwei\n## [ROLE] Rolle\nMaklerin\n## [REASONS] Gründe\n## [CONSEQUENCES] Folgen\n## [OPEN_POINTS] Offen\nKeine\n```");
using (Assert.EnterMultipleScope())
{
Assert.That(answer.Problems, Is.Empty);
Assert.That(answer.Sections["GOAL"], Is.EqualTo("Informieren\n- Punkt eins\n- Punkt zwei"));
Assert.That(answer.Sections["OPEN_POINTS"], Is.EqualTo("Keine"));
}
}
[Test]
public void AHeadingWithoutKeyBelongsToTheTextOfItsSection()
{
var answer = Read("## [GOAL] Ziel\n### Kurz gesagt\nInformieren\n## [ROLE]\n## [REASONS]\n## [CONSEQUENCES]\n## [OPEN_POINTS]");
Assert.That(answer.Sections["GOAL"], Is.EqualTo("### Kurz gesagt\nInformieren"));
}
[Test]
public void AMissingSectionIsAProblem()
{
var answer = Read("## [GOAL]\nInformieren\n## [ROLE]\n## [CONSEQUENCES]\n## [OPEN_POINTS]");
using (Assert.EnterMultipleScope())
{
Assert.That(answer.Problems, Has.Count.EqualTo(1));
Assert.That(answer.Problems[0], Does.Contain("[REASONS]"));
Assert.That(answer.Sections.Keys, Is.EquivalentTo(new[] { "GOAL", "ROLE", "CONSEQUENCES", "OPEN_POINTS" }));
}
}
[Test]
public void ADuplicateSectionIsAProblemAndTheFirstOneCounts()
{
var answer = Read("## [GOAL]\nErstens\n## [GOAL]\nZweitens\n## [ROLE]\n## [REASONS]\n## [CONSEQUENCES]\n## [OPEN_POINTS]");
using (Assert.EnterMultipleScope())
{
Assert.That(answer.Problems, Has.Count.EqualTo(1));
Assert.That(answer.Problems[0], Does.Contain("[GOAL]"));
Assert.That(answer.Sections["GOAL"], Is.EqualTo("Erstens"));
}
}
[Test]
public void AnUnknownSectionIsAProblem()
{
var answer = Read("## [GOAL]\n## [ROLE]\n## [REASONS]\n## [CONSEQUENCES]\n## [OPEN_POINTS]\n## [AUDIENCE] Publikum\nStudierende");
using (Assert.EnterMultipleScope())
{
Assert.That(answer.Problems, Has.Count.EqualTo(1));
Assert.That(answer.Problems[0], Does.Contain("[AUDIENCE]"));
Assert.That(answer.Sections.ContainsKey("AUDIENCE"), Is.False);
}
}
[Test]
public void AnAnswerWithoutSectionsIsAProblem()
{
var answer = Read("Ich habe leider keine Angaben.");
using (Assert.EnterMultipleScope())
{
Assert.That(answer.Problems, Has.Count.EqualTo(1));
Assert.That(answer.Sections, Is.Empty);
}
}
[Test]
public void ASuggestionForAnEmptyTextFieldIsRead()
{
var form = new BflStepFormData();
form.Set(BflFieldCatalog.FORMAT, "Podium");
var answer = BflMarkdownSections.Read(FrameworkWithSuggestions("## [FIELD:audience] Zielgruppe\nSchüler*innen\n## [field:EVENT_LINK]\nhttps://example.org/podium"), BflStep.FRAMEWORK, form);
using (Assert.EnterMultipleScope())
{
Assert.That(answer.Problems, Is.Empty);
Assert.That(answer.FieldSuggestions, Is.EqualTo(new Dictionary<string, string>
{
[BflFieldCatalog.AUDIENCE] = "Schüler*innen",
[BflFieldCatalog.EVENT_LINK] = "https://example.org/podium",
}));
}
}
[TestCase("## [FIELD:format]\nInterview", TestName = "AFieldWhichHoldsAValueGetsNoSuggestion")]
[TestCase("## [FIELD:unknown_field]\nWert", TestName = "AnUnknownFieldGetsNoSuggestion")]
[TestCase("## [FIELD:core_theses]\nThese", TestName = "AFieldOfAnotherStepGetsNoSuggestion")]
[TestCase("## [FIELD:event_link]\nexample.org", TestName = "ALinkWithoutSchemeIsNoSuggestion")]
[TestCase("## [FIELD:event_link]\njavascript:alert(1)", TestName = "ALinkOfAnotherSchemeIsNoSuggestion")]
[TestCase("## [FIELD:audience]\nErste Zeile\nZweite Zeile", TestName = "ASingleLineFieldGetsNoSuggestionWithSeveralLines")]
[TestCase("## [FIELD:audience]\n", TestName = "AnEmptySuggestionIsLeftOut")]
public void ASuggestionWhichDoesNotFitIsLeftOutWithoutAProblem(string suggestion)
{
var form = new BflStepFormData();
form.Set(BflFieldCatalog.FORMAT, "Podium");
var answer = BflMarkdownSections.Read(FrameworkWithSuggestions(suggestion), BflStep.FRAMEWORK, form);
using (Assert.EnterMultipleScope())
{
Assert.That(answer.FieldSuggestions, Is.Empty);
Assert.That(answer.Problems, Is.Empty);
}
}
[Test]
public void ASuggestionLongerThanItsFieldIsLeftOut()
{
var answer = BflMarkdownSections.Read(FrameworkWithSuggestions($"## [FIELD:audience]\n{new string('x', BflFieldKind.SINGLE_LINE.MaxLength + 1)}"), BflStep.FRAMEWORK, new BflStepFormData());
Assert.That(answer.FieldSuggestions, Is.Empty);
}
[Test]
public void TheFirstSuggestionForAFieldCounts()
{
var answer = BflMarkdownSections.Read(FrameworkWithSuggestions("## [FIELD:audience]\nErste\n## [FIELD:audience]\nZweite"), BflStep.FRAMEWORK, new BflStepFormData());
Assert.That(answer.FieldSuggestions[BflFieldCatalog.AUDIENCE], Is.EqualTo("Erste"));
}
[Test]
public void OnlyEmptyTextFieldsWhichAreNotPersonalGetSuggestions()
{
var values = BflMarkdownSections.SuggestableFields(BflStep.VALUES_AND_PERSPECTIVES, new BflStepFormData()).Select(field => field.Id);
var test = BflMarkdownSections.SuggestableFields(BflStep.TEST_SITUATION, new BflStepFormData()).Select(field => field.Id);
using (Assert.EnterMultipleScope())
{
Assert.That(values, Is.EqualTo(new[] { BflFieldCatalog.VALUES_AND_CONCERNS, BflFieldCatalog.OTHER_PERSPECTIVES }));
Assert.That(test, Is.EqualTo(new[] { BflFieldCatalog.TEST_NOTES }));
}
}
private static BflWorkStateAnswer Read(string answer) => BflMarkdownSections.Read(answer, BflStep.GOALS_AND_ROLE, new BflStepFormData());
private static string FrameworkWithSuggestions(string suggestions) => $"## [FORMAT]\n## [AUDIENCE]\n## [SPEAKING_TIME]\n## [PARTICIPANTS]\n## [CONFLICTS]\n## [OPEN_POINTS]\n{suggestions}";
}
@@ -67,11 +67,13 @@ public sealed class BflPromptLibraryTests
{
var core = string.Join("\n", BflPromptLibrary.PlaceholdersOf(BflPromptLibrary.CORE_PATH).Select(name => $"{{{{{name}}}}}"));
var onboarding = string.Join("\n", BflPromptLibrary.PlaceholdersOf(BflPromptLibrary.ONBOARDING_PATH).Select(name => $"{{{{{name}}}}}"));
var workState = string.Join("\n", BflPromptLibrary.PlaceholdersOf(BflPromptLibrary.WORK_STATE_PATH).Select(name => $"{{{{{name}}}}}"));
using (Assert.EnterMultipleScope())
{
Assert.That(BflPromptComposer.Compose(new(core, "Leitfaden", "Schritt", "Anrede"), BflStep.FRAMEWORK, new Dictionary<BflStep, BflStepFormData>(), [], "Deutsch"), Does.Not.Contain("{{"));
Assert.That(BflPromptComposer.ComposeOnboarding(new(onboarding, "Anrede"), "Deutsch"), Does.Not.Contain("{{"));
Assert.That(BflPromptComposer.ComposeWorkStateRequest(workState, BflStep.FRAMEWORK, new BflStepFormData()), Does.Not.Contain("{{"));
}
}
@@ -0,0 +1,241 @@
using AIStudio.Assistants.BeyondFactsLab;
namespace AIStudio.Tests.BeyondFactsLab;
/// <summary>
/// Checks when a confirmed work state of the BeyondFacts Lab counts as outdated.
/// </summary>
/// <remarks>
/// A confirmed work state is outdated as soon as the work state of an earlier step is confirmed for the first time,
/// confirmed anew, or withdrawn. A changed form of its own step gives a weaker notice.
/// </remarks>
[TestFixture]
public sealed class BflStalenessTests
{
private static readonly DateTimeOffset NOW = new(2026, 11, 10, 9, 0, 0, TimeSpan.Zero);
private static readonly Dictionary<string, string> SECTIONS = new(StringComparer.Ordinal) { ["SUMMARY"] = "Text" };
[Test]
public void AStepWithoutAConfirmationIsNotConfirmed()
{
var workStates = new Dictionary<BflStep, BflWorkState> { [BflStep.KNOWLEDGE] = new() { Draft = new(SECTIONS) } };
Assert.That(BflStaleness.Of(BflStep.KNOWLEDGE, workStates, Forms()), Is.EqualTo(BflWorkStateStatus.NOT_CONFIRMED));
}
[Test]
public void AConfirmationStaysValidWhileNothingChanges()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.FRAMEWORK);
Confirm(workStates, BflStep.KNOWLEDGE);
using (Assert.EnterMultipleScope())
{
Assert.That(BflStaleness.Of(BflStep.FRAMEWORK, workStates, Forms()), Is.EqualTo(BflWorkStateStatus.CONFIRMED));
Assert.That(BflStaleness.Of(BflStep.KNOWLEDGE, workStates, Forms()), Is.EqualTo(BflWorkStateStatus.CONFIRMED));
}
}
[TestCase(Change.CONFIRMED_ANEW, TestName = "AnEarlierStepConfirmedAnewOutdatesTheLaterOnes")]
[TestCase(Change.WITHDRAWN, TestName = "AnEarlierStepWithdrawnOutdatesTheLaterOnes")]
[TestCase(Change.WITHDRAWN_AND_CONFIRMED_AGAIN, TestName = "AnEarlierStepWithdrawnAndConfirmedAgainOutdatesTheLaterOnes")]
public void AChangeOfAnEarlierStepOutdatesTheLaterOnes(Change change)
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.FRAMEWORK);
Confirm(workStates, BflStep.GOALS_AND_ROLE);
switch (change)
{
case Change.CONFIRMED_ANEW:
Confirm(workStates, BflStep.FRAMEWORK);
break;
case Change.WITHDRAWN:
workStates[BflStep.FRAMEWORK].Withdraw();
break;
case Change.WITHDRAWN_AND_CONFIRMED_AGAIN:
workStates[BflStep.FRAMEWORK].Withdraw();
Confirm(workStates, BflStep.FRAMEWORK);
break;
}
Assert.That(BflStaleness.Of(BflStep.GOALS_AND_ROLE, workStates, Forms()), Is.EqualTo(BflWorkStateStatus.OUTDATED));
}
[Test]
public void AnEarlierStepConfirmedForTheFirstTimeOutdatesTheLaterOnes()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.STRATEGY);
Confirm(workStates, BflStep.KNOWLEDGE);
Assert.That(BflStaleness.Of(BflStep.STRATEGY, workStates, Forms()), Is.EqualTo(BflWorkStateStatus.OUTDATED));
}
[Test]
public void ALaterStepNeverOutdatesAnEarlierOne()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.KNOWLEDGE);
Confirm(workStates, BflStep.STRATEGY);
Confirm(workStates, BflStep.STRATEGY);
workStates[BflStep.STRATEGY].Withdraw();
Assert.That(BflStaleness.Of(BflStep.KNOWLEDGE, workStates, Forms()), Is.EqualTo(BflWorkStateStatus.CONFIRMED));
}
[Test]
public void ConfirmingAnOutdatedStepAgainMakesItValid()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.GOALS_AND_ROLE);
Confirm(workStates, BflStep.FRAMEWORK);
Confirm(workStates, BflStep.GOALS_AND_ROLE);
Assert.That(BflStaleness.Of(BflStep.GOALS_AND_ROLE, workStates, Forms()), Is.EqualTo(BflWorkStateStatus.CONFIRMED));
}
[Test]
public void ChangedDetailsOfTheStepGiveTheWeakerNotice()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
var form = new BflStepFormData();
form.Set(BflFieldCatalog.CONTRIBUTION_SUMMARY, "Erste Fassung");
Confirm(workStates, BflStep.KNOWLEDGE, form);
form.Set(BflFieldCatalog.CONTRIBUTION_SUMMARY, "Zweite Fassung");
Assert.That(BflStaleness.Of(BflStep.KNOWLEDGE, workStates, Forms((BflStep.KNOWLEDGE, form))), Is.EqualTo(BflWorkStateStatus.DETAILS_CHANGED));
}
[Test]
public void AnOutdatedStepWeighsMoreThanChangedDetails()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
var form = new BflStepFormData();
Confirm(workStates, BflStep.KNOWLEDGE, form);
form.Set(BflFieldCatalog.CORE_THESES, "Neu");
Confirm(workStates, BflStep.FRAMEWORK);
Assert.That(BflStaleness.Of(BflStep.KNOWLEDGE, workStates, Forms((BflStep.KNOWLEDGE, form))), Is.EqualTo(BflWorkStateStatus.OUTDATED));
}
[Test]
public void AFormThisVersionCannotOpenChangesNothing()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
var form = new BflStepFormData();
form.Set(BflFieldCatalog.CORE_THESES, "These");
Confirm(workStates, BflStep.KNOWLEDGE, form);
Assert.That(BflStaleness.Of(BflStep.KNOWLEDGE, workStates, new Dictionary<BflStep, BflStepFormData>()), Is.EqualTo(BflWorkStateStatus.CONFIRMED));
}
[Test]
public void TheFingerprintIgnoresLineEndingsAndSpacesAroundValues()
{
var unix = new BflStepFormData();
unix.Set(BflFieldCatalog.CORE_THESES, "Erste These\nZweite These");
var windows = new BflStepFormData();
windows.Set(BflFieldCatalog.CORE_THESES, " Erste These\r\nZweite These \n");
var changed = new BflStepFormData();
changed.Set(BflFieldCatalog.CORE_THESES, "Erste These\nDritte These");
using (Assert.EnterMultipleScope())
{
Assert.That(BflStaleness.FingerprintOf(windows), Is.EqualTo(BflStaleness.FingerprintOf(unix)));
Assert.That(BflStaleness.FingerprintOf(changed), Is.Not.EqualTo(BflStaleness.FingerprintOf(unix)));
}
}
[Test]
public void TheFingerprintTellsWhichFieldHoldsAValue()
{
var first = new BflStepFormData();
first.Set(BflFieldCatalog.CORE_THESES, "Text");
var second = new BflStepFormData();
second.Set(BflFieldCatalog.UNCERTAINTIES, "Text");
Assert.That(BflStaleness.FingerprintOf(first), Is.Not.EqualTo(BflStaleness.FingerprintOf(second)));
}
[Test]
public void AConfirmationRemembersOnlyEarlierSteps()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.FRAMEWORK);
Confirm(workStates, BflStep.STRATEGY);
Confirm(workStates, BflStep.KNOWLEDGE);
var revisions = BflStaleness.UpstreamRevisionsOf(BflStep.GOALS_AND_ROLE, workStates);
Assert.That(revisions.Keys, Is.EquivalentTo(new[] { BflStep.FRAMEWORK, BflStep.KNOWLEDGE }));
}
[Test]
public void ARevisionKeepsCountingAfterAWithdrawal()
{
var workState = new BflWorkState();
workState.Confirm(SECTIONS, new Dictionary<BflStep, int>(), string.Empty, NOW);
workState.Withdraw();
workState.Confirm(SECTIONS, new Dictionary<BflStep, int>(), string.Empty, NOW);
using (Assert.EnterMultipleScope())
{
Assert.That(workState.Confirmed!.Revision, Is.EqualTo(2));
Assert.That(workState.LastRevision, Is.EqualTo(2));
}
}
[Test]
public void AConfirmationDropsTheDraftAndEmptySections()
{
var workState = new BflWorkState { Draft = new(SECTIONS) };
workState.Confirm(new Dictionary<string, string> { ["SUMMARY"] = "Text", ["OPEN_POINTS"] = " " }, new Dictionary<BflStep, int>(), string.Empty, NOW);
using (Assert.EnterMultipleScope())
{
Assert.That(workState.Draft, Is.Null);
Assert.That(workState.Confirmed!.Sections.Keys, Is.EqualTo(new[] { "SUMMARY" }));
}
}
/// <summary>
/// What happens to an earlier step.
/// </summary>
public enum Change
{
CONFIRMED_ANEW,
WITHDRAWN,
WITHDRAWN_AND_CONFIRMED_AGAIN,
}
private static void Confirm(Dictionary<BflStep, BflWorkState> workStates, BflStep step, BflStepFormData? form = null)
{
if (!workStates.TryGetValue(step, out var workState))
{
workState = new BflWorkState();
workStates[step] = workState;
}
workState.Confirm(SECTIONS, BflStaleness.UpstreamRevisionsOf(step, workStates), BflStaleness.FingerprintOf(form ?? new BflStepFormData()), NOW);
}
private static Dictionary<BflStep, BflStepFormData> Forms(params (BflStep Step, BflStepFormData Form)[] forms)
{
var result = BflStep.All.ToDictionary(step => step, _ => new BflStepFormData());
foreach (var (step, form) in forms)
result[step] = form;
return result;
}
}
@@ -0,0 +1,143 @@
using AIStudio.Assistants.BeyondFactsLab;
using AIStudio.Settings.DataModel;
namespace AIStudio.Tests.BeyondFactsLab;
/// <summary>
/// Checks how the confirmed work states of the BeyondFacts Lab reach the prompts of the steps.
/// </summary>
/// <remarks>
/// Only what the person confirmed reaches a prompt: a draft never does, since the person has not checked it yet.
/// </remarks>
[TestFixture]
public sealed class BflWorkStatePromptTests
{
private static readonly BflPromptParts PARTS = new("{{WORK_STATES}}", "Leitfaden", "Schritt", "Anrede");
private static readonly DateTimeOffset NOW = new(2026, 11, 10, 9, 0, 0, TimeSpan.Zero);
[Test]
public void ADraftNeverReachesThePrompt()
{
var workStates = new Dictionary<BflStep, BflWorkState>
{
[BflStep.KNOWLEDGE] = new() { Draft = new(StringComparer.Ordinal) { ["ESTABLISHED"] = "ENTWURF-4711" } },
};
var prompt = Compose(BflStep.STRATEGY, workStates);
using (Assert.EnterMultipleScope())
{
Assert.That(prompt, Does.Not.Contain("ENTWURF-4711"));
Assert.That(prompt, Is.EqualTo("Die Person hat noch keinen Arbeitsstand bestätigt."));
}
}
[Test]
public void AConfirmedWorkStateReachesThePromptsOfOtherStepsWithItsSections()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.KNOWLEDGE, new() { ["ESTABLISHED"] = "BESTÄTIGT-4711", ["UNCERTAINTIES"] = " " });
var prompt = Compose(BflStep.STRATEGY, workStates);
var established = BflWorkStateSections.ForStep(BflStep.KNOWLEDGE).Single(section => section.Key == "ESTABLISHED");
var uncertainties = BflWorkStateSections.ForStep(BflStep.KNOWLEDGE).Single(section => section.Key == "UNCERTAINTIES");
using (Assert.EnterMultipleScope())
{
Assert.That(prompt, Does.Contain($"## {BflStep.KNOWLEDGE.Title}\n### {established.Title}\nBESTÄTIGT-4711"));
Assert.That(prompt, Does.Not.Contain(uncertainties.Title), "An empty section is left out.");
}
}
[Test]
public void TheWorkStateOfTheOwnStepIsMarked()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.GOALS_AND_ROLE, new() { ["GOAL"] = "Informieren" });
var prompt = Compose(BflStep.GOALS_AND_ROLE, workStates);
Assert.That(prompt, Does.StartWith($"## {BflStep.GOALS_AND_ROLE.Title} (dieser Schritt)"));
}
[Test]
public void APersonalSectionReachesOnlyTheChatOfItsOwnStep()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.VALUES_AND_PERSPECTIVES, new() { ["VALUES"] = "Gerechtigkeit", ["PERSONAL_INVOLVEMENT"] = "PERSÖNLICH-4711" });
var own = Compose(BflStep.VALUES_AND_PERSPECTIVES, workStates);
var other = Compose(BflStep.STRATEGY, workStates);
using (Assert.EnterMultipleScope())
{
Assert.That(own, Does.Contain("PERSÖNLICH-4711"));
Assert.That(own, Does.Contain("(persönliche Angabe)"));
Assert.That(other, Does.Not.Contain("PERSÖNLICH-4711"));
Assert.That(other, Does.Contain("Gerechtigkeit"));
}
}
[Test]
public void AWorkStateWithOnlyAPersonalSectionIsLeftOutOfOtherSteps()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.VALUES_AND_PERSPECTIVES, new() { ["PERSONAL_INVOLVEMENT"] = "PERSÖNLICH-4711" });
Assert.That(Compose(BflStep.STRATEGY, workStates), Is.EqualTo("Die Person hat noch keinen Arbeitsstand bestätigt."));
}
[Test]
public void AnOutdatedWorkStateIsMarked()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.GOALS_AND_ROLE, new() { ["GOAL"] = "Informieren" });
Confirm(workStates, BflStep.FRAMEWORK, new() { ["FORMAT"] = "Podium" });
var prompt = Compose(BflStep.STRATEGY, workStates);
using (Assert.EnterMultipleScope())
{
Assert.That(prompt, Does.Contain($"## {BflStep.GOALS_AND_ROLE.Title} (veraltet)"));
Assert.That(prompt, Does.Contain($"## {BflStep.FRAMEWORK.Title}\n"));
}
}
[Test]
public void ASectionThisVersionDoesNotKnowIsLeftOut()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.GOALS_AND_ROLE, new() { ["GOAL"] = "Informieren", ["FUTURE_SECTION"] = "ZUKUNFT-4711" });
Assert.That(Compose(BflStep.STRATEGY, workStates), Does.Not.Contain("ZUKUNFT-4711"));
}
[Test]
public async Task TheBuiltInPromptOfEveryStepCarriesTheConfirmedWorkStates()
{
var workStates = new Dictionary<BflStep, BflWorkState>();
Confirm(workStates, BflStep.FRAMEWORK, new() { ["FORMAT"] = "BESTÄTIGT-4711" });
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", workStates);
Assert.That(prompt, Does.Contain("BESTÄTIGT-4711"), step.ToString());
}
}
private static string Compose(BflStep step, IReadOnlyDictionary<BflStep, BflWorkState> workStates) => BflPromptComposer.Compose(PARTS, step, new Dictionary<BflStep, BflStepFormData>(), [], "Deutsch", workStates);
private static void Confirm(Dictionary<BflStep, BflWorkState> workStates, BflStep step, Dictionary<string, string> sections)
{
if (!workStates.TryGetValue(step, out var workState))
{
workState = new BflWorkState();
workStates[step] = workState;
}
workState.Confirm(sections, BflStaleness.UpstreamRevisionsOf(step, workStates), BflStaleness.FingerprintOf(new BflStepFormData()), NOW);
}
}
@@ -0,0 +1,72 @@
using AIStudio.Assistants.BeyondFactsLab;
namespace AIStudio.Tests.BeyondFactsLab;
/// <summary>
/// Checks the request which lets the model write the work state of a step in the BeyondFacts Lab.
/// </summary>
[TestFixture]
public sealed class BflWorkStateRequestTests
{
[Test]
public async Task TheRequestOfEveryStepNamesEachOfItsSectionsWithItsHeading()
{
var template = await BflPromptLibrary.ReadAsync(BflPromptLibrary.WORK_STATE_PATH);
foreach (var step in BflStep.All)
{
var request = BflPromptComposer.ComposeWorkStateRequest(template, step, new BflStepFormData());
using (Assert.EnterMultipleScope())
{
Assert.That(request, Does.Not.Contain("{{"), step.ToString());
foreach (var section in BflWorkStateSections.ForStep(step))
Assert.That(request, Does.Contain($"## [{section.Key}] {section.Title}"), $"{step}: {section.Key}");
}
}
}
[Test]
public void TheRequestOffersSuggestionsOnlyForEmptyTextFields()
{
var form = new BflStepFormData();
form.Set(BflFieldCatalog.FORMAT, "Podium");
var request = BflPromptComposer.ComposeWorkStateRequest("{{SECTIONS}}\n{{FIELDS}}", BflStep.FRAMEWORK, form);
using (Assert.EnterMultipleScope())
{
Assert.That(request, Does.Contain($"[{BflMarkdownSections.FIELD_PREFIX}{BflFieldCatalog.AUDIENCE}]"));
Assert.That(request, Does.Contain($"[{BflMarkdownSections.FIELD_PREFIX}{BflFieldCatalog.EVENT_LINK}]"));
Assert.That(request, Does.Not.Contain($"[{BflMarkdownSections.FIELD_PREFIX}{BflFieldCatalog.FORMAT}]"));
}
}
[Test]
public void ARequestWithoutEmptyFieldsAsksForNoSuggestions()
{
var form = new BflStepFormData();
form.Set(BflFieldCatalog.COMMUNICATION_WORRIES, "Sorge");
var request = BflPromptComposer.ComposeWorkStateRequest("{{FIELDS}}", BflStep.STRATEGY, form);
Assert.That(request, Does.Not.Contain(BflMarkdownSections.FIELD_PREFIX));
}
[Test]
public void TheRequestToCorrectAWorkStateNamesEveryProblem()
{
var request = BflPromptComposer.ComposeWorkStateRepairRequest(["Der Abschnitt [GOAL] fehlt.", "Den Abschnitt [X] gibt es in diesem Schritt nicht."]);
using (Assert.EnterMultipleScope())
{
Assert.That(request, Does.Contain("- Der Abschnitt [GOAL] fehlt."));
Assert.That(request, Does.Contain("- Den Abschnitt [X] gibt es in diesem Schritt nicht."));
}
}
[Test]
public void TheBuiltInRequestIsAPromptTheTestFolderMayReplace()
{
Assert.That(BflPromptLibrary.AllPaths, Does.Contain(BflPromptLibrary.WORK_STATE_PATH));
}
}
+2 -1
View File
@@ -61,10 +61,11 @@ public static partial class JsonTestRoots
yield return Case(new(typeof(IReadOnlyList<string>), SqliteProvenanceStoreClientImplementation.JSON_OPTIONS));
yield return Case(new(typeof(List<string>), SqliteProvenanceStoreClientImplementation.JSON_OPTIONS));
// The preparations of the BeyondFacts Lab, their forms and materials, and the preparation selected last:
// The preparations of the BeyondFacts Lab, their forms, materials, and work states, and the preparation selected last:
yield return Case(new(typeof(BflProject), BeyondFactsLabJson.PERSISTENCE));
yield return Case(new(typeof(BflStepFormData), BeyondFactsLabJson.PERSISTENCE));
yield return Case(new(typeof(BflMaterial), BeyondFactsLabJson.PERSISTENCE));
yield return Case(new(typeof(BflWorkState), BeyondFactsLabJson.PERSISTENCE));
yield return Case(new(typeof(Guid?), BeyondFactsLabJson.PERSISTENCE), "selected preparation");
}