diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs index 8727e329e..f74eca8fd 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Forms.cs @@ -113,12 +113,19 @@ public sealed partial class BeyondFactsLabStore throw new InvalidDataException("This version of AI Studio cannot open the form.", exception); } - if (form is null || form.SchemaVersion is < 1 or > BflStepFormData.CURRENT_SCHEMA_VERSION || IsNull(form.Values) || form.CardSort is { } cardSort && IsBroken(cardSort)) + if (form is null || form.SchemaVersion is < 1 or > BflStepFormData.CURRENT_SCHEMA_VERSION || IsNull(form.Values) || form.CardSort is { } cardSort && IsBroken(cardSort) || form.Simulation is { } simulation && IsBroken(simulation)) throw new InvalidDataException("This version of AI Studio cannot open the form."); return form; } + /// + /// Tells whether a stored simulation lacks one of its parts, which JSON can make null, or names a mode this version + /// does not know. + /// + /// The simulation. + private static bool IsBroken(BflSimulation simulation) => !Enum.IsDefined(simulation.Mode) || IsNull(simulation.People) || simulation.People.Any(IsNull); + /// /// Tells whether a stored card sort lacks one of its parts, which JSON can make null. /// diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflEffectiveSimulation.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflEffectiveSimulation.cs new file mode 100644 index 000000000..8826a5500 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflEffectiveSimulation.cs @@ -0,0 +1,35 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Whom the chat of the step "Test situation" of the BeyondFacts Lab actually plays: the mode and the people which +/// count of what the person chose. +/// +/// +/// A chosen person counts while they are a candidate, see BflSimulationCandidates, and only as many as the mode allows, +/// in the order of the list of participants. When nobody of the chosen people counts, the chat plays generic +/// counterparts, as it did before the person chose a mode. +/// +/// The mode in effect. +/// The research on the people the chat plays, in the order of the list; empty for generic counterparts. +public sealed record BflEffectiveSimulation(BflSimulationMode Mode, IReadOnlyList People) +{ + /// + /// The simulation with generic counterparts, no specific person. + /// + public static readonly BflEffectiveSimulation GENERIC = new(BflSimulationMode.GENERIC, []); + + /// + /// Gets whom the chat plays of what the person chose. + /// + /// What the person chose; null when they chose nothing. + /// The people the chat may play, in the order of the list, see BflSimulationCandidates. + /// The simulation in effect. + public static BflEffectiveSimulation Of(BflSimulation? simulation, IReadOnlyList candidates) + { + if (simulation is null || !simulation.Mode.HasPeople) + return GENERIC; + + var people = candidates.Where(candidate => simulation.HasChosen(candidate.Name)).Take(simulation.Mode.MaxPeople).ToList(); + return people.Count is 0 ? GENERIC : new(simulation.Mode, people); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulation.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulation.cs new file mode 100644 index 000000000..c52b055e5 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulation.cs @@ -0,0 +1,31 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Whom the person chose for the chat of the step "Test situation" of the BeyondFacts Lab to play: the mode, and the +/// people from the research on single participants. +/// +/// +/// The choice belongs to the details of its step and is stored with them, see BflStepFormData.Simulation. It is the +/// opt-in of the person: the chat plays a real person only while the person chose them here. What counts is the +/// effective simulation, see BflEffectiveSimulation: a name which is no longer a candidate stays stored, but counts no +/// longer, and so do the names beyond what the mode allows. +/// +public sealed class BflSimulation +{ + /// + /// Gets or sets the mode. + /// + public BflSimulationMode Mode { get; set; } = BflSimulationMode.GENERIC; + + /// + /// Gets or sets the names of the people the person chose, as the list of participants gives them, in no particular + /// order. + /// + public List People { get; set; } = []; + + /// + /// Tells whether the person chose a person, regardless of case and of the spaces between the words. + /// + /// The name of the person. + public bool HasChosen(string name) => this.People.Any(chosen => string.Equals(BflParticipant.NameKey(chosen), BflParticipant.NameKey(name), StringComparison.Ordinal)); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationBudget.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationBudget.cs new file mode 100644 index 000000000..df4fa07c7 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationBudget.cs @@ -0,0 +1,52 @@ +using AIStudio.Models; + +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Estimates whether the context window of a model holds the simulation the person chose in the step "Test situation" +/// of the BeyondFacts Lab. +/// +/// +/// The prompt of the step carries the dossier of every person the chat plays, in full: unlike the material, a dossier +/// is never shortened, since the chat must not play a person after half of what they said. The estimate adds the +/// dossiers to the smallest window the lab accepts at all, which leaves room for the fixed part of the prompt, the +/// details, and the conversation, and counts the characters of a dossier the way the budget of the material does. The +/// material then gets what is left, see BflMaterialBudget. +/// +public static class BflSimulationBudget +{ + /// + /// Estimates how many tokens the dossiers of the people the chat plays take in the prompt. + /// + /// The research on the people the chat plays. + /// The estimated tokens. + public static int DossierTokens(IReadOnlyList people) + { + var characters = people.Sum(person => person.Stages.Sum(stage => stage.Research.Text.Trim().Length)); + return (characters + BflMaterialBudget.CHARACTERS_PER_TOKEN - 1) / BflMaterialBudget.CHARACTERS_PER_TOKEN; + } + + /// + /// Estimates how many tokens a model needs to read at once for a simulation. + /// + /// The research on the people the chat plays. + /// The estimated tokens. + public static int RequiredTokens(IReadOnlyList people) => BflModelRequirements.MINIMUM_CONTEXT_TOKENS + DossierTokens(people); + + /// + /// Checks whether the context window of a model is likely to hold a simulation. + /// + /// The simulation in effect. + /// The context window of the model, as it ships. + /// Whether the window holds the simulation, nobody knows, or it is likely too small. + public static BflSimulationFit Check(BflEffectiveSimulation simulation, ContextWindow contextWindow) + { + if (simulation.People.Count is 0) + return BflSimulationFit.FITS; + + if (!contextWindow.IsKnown) + return BflSimulationFit.UNKNOWN_CONTEXT; + + return contextWindow.DefaultTokens < RequiredTokens(simulation.People) ? BflSimulationFit.TOO_SMALL : BflSimulationFit.FITS; + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationCandidates.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationCandidates.cs new file mode 100644 index 000000000..5baaf0abf --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationCandidates.cs @@ -0,0 +1,35 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Decides whom of the participants the chat of the step "Test situation" of the BeyondFacts Lab may play. +/// +/// +/// Only a person with a documented public position qualifies, so that the chat never makes up what somebody stands +/// for: the person stands on the current list of participants, and the stage "Positions" of the research on them is +/// done and holds text. For an event which is not public, no research counts, see BflEventVisibility, and nobody +/// qualifies. The prompt of the simulation still lets the chat refuse to play a person whose dossier documents no +/// position after all, since only the model reads what the text says. +/// +public static class BflSimulationCandidates +{ + /// + /// Gets the people the chat of the step "Test situation" may play. + /// + /// The forms of the preparation, by their steps. + /// The research on the topics, with the list of participants. + /// The research on every person stored for the preparation. + /// The research on each person who qualifies, in the order of the list. + public static IReadOnlyList Of(IReadOnlyDictionary forms, IReadOnlyDictionary topics, IReadOnlyList people) + { + if (BflEventVisibility.IsNotPublic(forms)) + return []; + + return [.. BflStepResearch.Of(topics, people).People.Where(HasPositions)]; + } + + /// + /// Tells whether the research on a person documents public positions: its stage "Positions" is done and holds text. + /// + /// The research on the person. + public static bool HasPositions(BflPersonResearch person) => person.StageOf(BflPersonResearchStage.POSITIONS) is { } positions && positions.Text.Trim().Length > 0; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationFit.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationFit.cs new file mode 100644 index 000000000..bf60e368b --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationFit.cs @@ -0,0 +1,23 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Whether the context window of a model is likely to hold the simulation the person chose in the step "Test +/// situation" of the BeyondFacts Lab, see BflSimulationBudget. +/// +public enum BflSimulationFit +{ + /// + /// The window is known and holds the simulation, or the simulation plays nobody from the research. + /// + FITS, + + /// + /// Nobody knows the window of the model, so the lab cannot tell; it warns the person and lets the chat run. + /// + UNKNOWN_CONTEXT, + + /// + /// The window is known and likely too small; the chat does not send. + /// + TOO_SMALL, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationMode.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationMode.cs new file mode 100644 index 000000000..1a87eb005 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationMode.cs @@ -0,0 +1,31 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// Whom the chat of the step "Test situation" of the BeyondFacts Lab plays: generic counterparts, or people from the +/// research on single participants in a format of the event. +/// +/// +/// The names are stored, so they never change. A new format joins as a new member, see BflSimulationModeExtensions. +/// +public enum BflSimulationMode +{ + /// + /// Generic counterparts, no specific person: the step as it was before the lab simulated people. + /// + GENERIC, + + /// + /// One person from the research, after their dossier. + /// + SINGLE_PERSON, + + /// + /// A panel discussion with up to three people from the research. + /// + PANEL_DISCUSSION, + + /// + /// A press conference with up to five people from the research. + /// + PRESS_CONFERENCE, +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationModeExtensions.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationModeExtensions.cs new file mode 100644 index 000000000..769c357e4 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflSimulationModeExtensions.cs @@ -0,0 +1,47 @@ +namespace AIStudio.Assistants.BeyondFactsLab; + +/// +/// What the BeyondFacts Lab shows and allows for a mode of the simulation in the step "Test situation". +/// +public static class BflSimulationModeExtensions +{ + private static string TB(string fallbackEN) => Tools.PluginSystem.I18N.I.T(fallbackEN, typeof(BflSimulationModeExtensions).Namespace, nameof(BflSimulationModeExtensions)); + + /// The mode. + extension(BflSimulationMode mode) + { + /// + /// Gets the name of the mode, as the selection shows it. + /// + public string Title => mode switch + { + BflSimulationMode.SINGLE_PERSON => TB("One person from the research"), + BflSimulationMode.PANEL_DISCUSSION => TB("Panel discussion with up to three people"), + BflSimulationMode.PRESS_CONFERENCE => TB("Press conference with up to five people"), + + _ => TB("Generic counterparts, no specific person"), + }; + + /// + /// Gets how many people from the research the chat plays at most in this mode; none for generic counterparts. + /// + public int MaxPeople => mode switch + { + BflSimulationMode.SINGLE_PERSON => 1, + BflSimulationMode.PANEL_DISCUSSION => 3, + BflSimulationMode.PRESS_CONFERENCE => 5, + + _ => 0, + }; + + /// + /// Gets whether the chat plays people from the research in this mode. + /// + public bool HasPeople => mode.MaxPeople > 0; + + /// + /// Gets every mode, in the order the selection shows them. + /// + public static IReadOnlyList All => Enum.GetValues(); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStaleness.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStaleness.cs index 9314f70e7..3efac8e56 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStaleness.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStaleness.cs @@ -96,7 +96,8 @@ public static class BflStaleness /// /// Line endings and spaces around a value do not count, since they change nothing the model reads. The card sort /// counts as a detail of its step; without one, or with an empty one, the fingerprint is the same as before the lab - /// had a card sort, so that no confirmation of an older preparation turns into "Details changed". + /// had a card sort, so that no confirmation of an older preparation turns into "Details changed". The same holds for + /// the simulation of the step "Test situation" with generic counterparts. /// /// The form. /// The fingerprint, in hexadecimal digits. @@ -115,9 +116,28 @@ public static class BflStaleness if (form.CardSort is { IsEmpty: false } cardSort) AppendCardSort(text, cardSort); + if (form.Simulation is { Mode.HasPeople: true } simulation) + AppendSimulation(text, simulation); + return Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text.ToString()))); } + /// + /// Appends a simulation with people to the text a fingerprint is computed from. + /// + /// + /// Generic counterparts add nothing, so that the fingerprint stays the same as before the lab simulated people. The + /// people count as a set, by the keys of their names: the order the person chose them in means nothing. + /// + /// The text so far. + /// The simulation. + private static void AppendSimulation(StringBuilder text, BflSimulation simulation) + { + text.Append(SEPARATOR).Append("simulation").Append(SEPARATOR).Append(simulation.Mode.ToString()).Append(SEPARATOR); + foreach (var key in simulation.People.Select(BflParticipant.NameKey).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)) + text.Append(key).Append(SEPARATOR); + } + /// /// Appends a card sort to the text a fingerprint is computed from. /// diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs index 72c7be123..62da9a706 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflStepFormData.cs @@ -7,14 +7,15 @@ namespace AIStudio.Assistants.BeyondFactsLab; /// /// /// The values are stored under the IDs of the fields of BflFieldCatalog. A value of a field this version does not -/// know, e.g., one a newer version added, stays as it is and is written back unchanged. Version 2 added the card sort. +/// know, e.g., one a newer version added, stays as it is and is written back unchanged. Version 2 added the card sort, +/// version 3 the simulation. /// public sealed class BflStepFormData { /// /// The version of the stored form this version of AI Studio writes. Raise it with every new property. /// - public const int CURRENT_SCHEMA_VERSION = 2; + public const int CURRENT_SCHEMA_VERSION = 3; /// /// The character which separates the options of a field with several choices. @@ -40,6 +41,15 @@ public sealed class BflStepFormData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public BflCardSort? CardSort { get; set; } + /// + /// Gets or sets whom the chat of the step "Test situation" plays, see BflSimulation. + /// + /// + /// Null for every other step, and until the person chooses a mode there; the file then leaves it out. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public BflSimulation? Simulation { get; set; } + /// /// Gets the value of a field, or an empty string when the field has none. /// diff --git a/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs b/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs index bf85160de..e65606c6c 100644 --- a/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs +++ b/app/Tests/BeyondFactsLab/BeyondFactsLabStoreTests.cs @@ -405,6 +405,54 @@ public sealed class BeyondFactsLabStoreTests await Assert.ThrowsAsync(async () => await store.LoadFormAsync(created.ProjectId, BflStep.VALUES_AND_PERSPECTIVES)); } + [Test] + public async Task ASimulationIsStoredWithTheFormOfItsStep() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + var form = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.PANEL_DISCUSSION, People = ["Eva Klein", "Frank Ott"] } }; + + await store.SaveFormAsync(created.ProjectId, BflStep.TEST_SITUATION, form); + var json = await File.ReadAllTextAsync(Path.Combine(this.RootDirectory, created.ProjectId.ToString("D"), "steps", "06-test", "form.json")); + var loaded = (await this.CreateStore().LoadFormAsync(created.ProjectId, BflStep.TEST_SITUATION)).Simulation; + + using (Assert.EnterMultipleScope()) + { + Assert.That(json, Does.Contain("\"PANEL_DISCUSSION\""), "The mode is stored by its name, which never changes."); + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded!.Mode, Is.EqualTo(BflSimulationMode.PANEL_DISCUSSION)); + Assert.That(loaded.People, Is.EqualTo(new[] { "Eva Klein", "Frank Ott" })); + } + } + + [Test] + public async Task AFormOfTheSecondVersionIsStillOpenedWithoutASimulation() + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + await this.WriteFormFileAsync(created.ProjectId, "06-test", """{ "schemaVersion": 2, "values": { "test_notes": "Text" } }"""); + + var form = await store.LoadFormAsync(created.ProjectId, BflStep.TEST_SITUATION); + + using (Assert.EnterMultipleScope()) + { + Assert.That(form.Get(BflFieldCatalog.TEST_NOTES), Is.EqualTo("Text")); + Assert.That(form.Simulation, Is.Null); + } + } + + [TestCase("""{ "schemaVersion": 3, "values": {}, "simulation": { "mode": "PANEL_DISCUSSION", "people": null } }""")] + [TestCase("""{ "schemaVersion": 3, "values": {}, "simulation": { "mode": "PANEL_DISCUSSION", "people": [ null ] } }""")] + [TestCase("""{ "schemaVersion": 3, "values": {}, "simulation": { "mode": 42, "people": [] } }""")] + public async Task AFormWithABrokenSimulationIsNotOpened(string json) + { + var store = this.CreateStore(); + var created = await store.CreateAsync("Panel"); + await this.WriteFormFileAsync(created.ProjectId, "06-test", json); + + await Assert.ThrowsAsync(async () => await store.LoadFormAsync(created.ProjectId, BflStep.TEST_SITUATION)); + } + [Test] public async Task AStepWithoutAFormGetsAnEmptyOne() { @@ -463,7 +511,7 @@ public sealed class BeyondFactsLabStoreTests [Test] public async Task AFormOfANewerVersionIsNotOverwritten() { - const string NEWER = """{ "schemaVersion": 3, "values": {} }"""; + const string NEWER = """{ "schemaVersion": 4, "values": {} }"""; var store = this.CreateStore(); var created = await store.CreateAsync("Panel"); var formPath = await this.WriteFormFileAsync(created.ProjectId, "05-strategy", NEWER); diff --git a/app/Tests/BeyondFactsLab/BflSimulationTests.cs b/app/Tests/BeyondFactsLab/BflSimulationTests.cs new file mode 100644 index 000000000..714ba59e4 --- /dev/null +++ b/app/Tests/BeyondFactsLab/BflSimulationTests.cs @@ -0,0 +1,173 @@ +using AIStudio.Assistants.BeyondFactsLab; +using AIStudio.Models; + +namespace AIStudio.Tests.BeyondFactsLab; + +/// +/// Checks whom the chat of the step "Test situation" of the BeyondFacts Lab may play, whom it plays of what the person +/// chose, and whether a model reads enough for it. +/// +[TestFixture] +public sealed class BflSimulationTests +{ + private static readonly DateTimeOffset CREATED_AT_UTC = new(2026, 11, 15, 10, 0, 0, TimeSpan.Zero); + + [Test] + public void OnlyAListedPersonWithDocumentedPositionsQualifies() + { + var forms = Forms(); + BflPersonResearch[] people = + [ + Person(forms, "Eva Klein", BflPersonResearchStage.All), + Person(forms, "Frank Ott", [BflPersonResearchStage.PROFILE]), + Person(forms, "Gina Roth", [BflPersonResearchStage.PROFILE, BflPersonResearchStage.POSITIONS], positionsText: " "), + Person(forms, "Nicht Gelistet", BflPersonResearchStage.All) + ]; + + var candidates = BflSimulationCandidates.Of(forms, Topics(forms, "Eva Klein", "Frank Ott", "Gina Roth"), people); + + Assert.That(candidates.Select(person => person.Name), Is.EqualTo(new[] { "Eva Klein" })); + } + + [Test] + public void TheCandidatesComeInTheOrderOfTheList() + { + var forms = Forms(); + BflPersonResearch[] people = [Person(forms, "Eva Klein", BflPersonResearchStage.All), Person(forms, "Frank Ott", BflPersonResearchStage.All)]; + + var candidates = BflSimulationCandidates.Of(forms, Topics(forms, "Frank Ott", "Eva Klein"), people); + + Assert.That(candidates.Select(person => person.Name), Is.EqualTo(new[] { "Frank Ott", "Eva Klein" })); + } + + [Test] + public void NobodyQualifiesForAnEventWhichIsNotPublic() + { + var forms = Forms(); + BflPersonResearch[] people = [Person(forms, "Eva Klein", BflPersonResearchStage.All)]; + var topics = Topics(forms, "Eva Klein"); + forms[BflStep.FRAMEWORK].Set(BflFieldCatalog.EVENT_VISIBILITY, BflFieldCatalog.EVENT_NOT_PUBLIC); + + Assert.That(BflSimulationCandidates.Of(forms, topics, people), Is.Empty); + } + + [Test] + public void GenericCounterpartsPlayNobody() + { + var candidates = Candidates("Eva Klein"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(BflEffectiveSimulation.Of(null, candidates), Is.SameAs(BflEffectiveSimulation.GENERIC)); + Assert.That(BflEffectiveSimulation.Of(new() { Mode = BflSimulationMode.GENERIC, People = ["Eva Klein"] }, candidates), Is.SameAs(BflEffectiveSimulation.GENERIC)); + } + } + + [TestCase(BflSimulationMode.SINGLE_PERSON, 1)] + [TestCase(BflSimulationMode.PANEL_DISCUSSION, 3)] + [TestCase(BflSimulationMode.PRESS_CONFERENCE, 5)] + public void AModePlaysAtMostItsNumberOfPeopleInTheOrderOfTheList(BflSimulationMode mode, int expected) + { + string[] names = ["Anna Arendt", "Bruno Berg", "Clara Cramer", "David Dorn", "Eva Klein", "Frank Ott"]; + var candidates = Candidates(names); + var simulation = new BflSimulation { Mode = mode, People = [.. names.Reverse()] }; + + var effective = BflEffectiveSimulation.Of(simulation, candidates); + + using (Assert.EnterMultipleScope()) + { + Assert.That(mode.MaxPeople, Is.EqualTo(expected)); + Assert.That(effective.Mode, Is.EqualTo(mode)); + Assert.That(effective.People.Select(person => person.Name), Is.EqualTo(names.Take(expected))); + } + } + + [Test] + public void APersonWhoIsNoLongerACandidateCountsNoLonger() + { + var candidates = Candidates("Eva Klein", "Frank Ott"); + var simulation = new BflSimulation { Mode = BflSimulationMode.PANEL_DISCUSSION, People = ["Nicht Mehr Gelistet", "frank ott"] }; + + var effective = BflEffectiveSimulation.Of(simulation, candidates); + + using (Assert.EnterMultipleScope()) + { + Assert.That(effective.People.Select(person => person.Name), Is.EqualTo(new[] { "Frank Ott" })); + Assert.That(simulation.People, Has.Count.EqualTo(2), "What the person chose stays stored."); + } + } + + [Test] + public void AModeWithoutAnyChosenCandidateFallsBackToGenericCounterparts() + { + var candidates = Candidates("Eva Klein"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(BflEffectiveSimulation.Of(new() { Mode = BflSimulationMode.SINGLE_PERSON }, candidates), Is.SameAs(BflEffectiveSimulation.GENERIC)); + Assert.That(BflEffectiveSimulation.Of(new() { Mode = BflSimulationMode.PRESS_CONFERENCE, People = ["Frank Ott"] }, candidates), Is.SameAs(BflEffectiveSimulation.GENERIC)); + } + } + + [Test] + public void TheDossiersAreCountedWithTheCharactersPerTokenOfTheMaterial() + { + var person = new BflPersonResearch { Name = "Eva Klein" }; + person.Set(BflPersonResearchStage.PROFILE, new BflResearch { Text = new string('a', 4_000), CreatedAtUtc = CREATED_AT_UTC }); + person.Set(BflPersonResearchStage.POSITIONS, new BflResearch { Text = $" {new string('b', 3_999)}\n", CreatedAtUtc = CREATED_AT_UTC }); + + using (Assert.EnterMultipleScope()) + { + Assert.That(BflSimulationBudget.DossierTokens([person]), Is.EqualTo(2_000)); + Assert.That(BflSimulationBudget.RequiredTokens([person]), Is.EqualTo(BflModelRequirements.MINIMUM_CONTEXT_TOKENS + 2_000)); + } + } + + [Test] + public void AKnownWindowWhichIsTooSmallRefusesAndAnUnknownOneWarns() + { + var person = new BflPersonResearch { Name = "Eva Klein" }; + person.Set(BflPersonResearchStage.POSITIONS, new BflResearch { Text = new string('a', 40_000), CreatedAtUtc = CREATED_AT_UTC }); + var simulation = new BflEffectiveSimulation(BflSimulationMode.SINGLE_PERSON, [person]); + var required = BflSimulationBudget.RequiredTokens([person]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(BflSimulationBudget.Check(simulation, ContextWindow.Of(required - 1)), Is.EqualTo(BflSimulationFit.TOO_SMALL)); + Assert.That(BflSimulationBudget.Check(simulation, ContextWindow.Of(required)), Is.EqualTo(BflSimulationFit.FITS)); + Assert.That(BflSimulationBudget.Check(simulation, ContextWindow.UNKNOWN), Is.EqualTo(BflSimulationFit.UNKNOWN_CONTEXT)); + Assert.That(BflSimulationBudget.Check(BflEffectiveSimulation.GENERIC, ContextWindow.Of(8_192)), Is.EqualTo(BflSimulationFit.FITS), "Generic counterparts need no room for dossiers."); + Assert.That(BflSimulationBudget.Check(BflEffectiveSimulation.GENERIC, ContextWindow.UNKNOWN), Is.EqualTo(BflSimulationFit.FITS)); + } + } + + private static List Candidates(params string[] names) + { + var forms = Forms(); + return [.. BflSimulationCandidates.Of(forms, Topics(forms, names), [.. names.Select(name => Person(forms, name, BflPersonResearchStage.All))])]; + } + + private static BflPersonResearch Person(IReadOnlyDictionary forms, string name, IEnumerable stages, string? positionsText = null) + { + var person = new BflPersonResearch { Name = name }; + foreach (var stage in stages) + person.Set(stage, BflResearch.Of(stage is BflPersonResearchStage.POSITIONS && positionsText is not null ? positionsText : $"Text {stage}", [], [], BflPromptComposer.PublicDetailsOf(forms), CREATED_AT_UTC)); + + return person; + } + + private static Dictionary Topics(IReadOnlyDictionary forms, params string[] names) + { + var list = BflResearch.Of(string.Join("\n\n", names.Select(name => $"### {name}")), [], [], BflPromptComposer.PublicDetailsOf(forms), CREATED_AT_UTC); + list.Participants = [.. BflParticipant.ListIn(list.Text)]; + return new() { [BflResearchTopic.PARTICIPANTS] = list }; + } + + private static Dictionary Forms() + { + var forms = BflStep.All.ToDictionary(step => step, _ => new BflStepFormData()); + forms[BflStep.FRAMEWORK].Set(BflFieldCatalog.FORMAT, "Podium"); + forms[BflStep.FRAMEWORK].Set(BflFieldCatalog.EVENT_TOPIC, "Klimapolitik"); + return forms; + } +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflStalenessTests.cs b/app/Tests/BeyondFactsLab/BflStalenessTests.cs index 8f427aa0c..725c25854 100644 --- a/app/Tests/BeyondFactsLab/BflStalenessTests.cs +++ b/app/Tests/BeyondFactsLab/BflStalenessTests.cs @@ -213,6 +213,46 @@ public sealed class BflStalenessTests Assert.That(BflStaleness.FingerprintOf(first), Is.EqualTo(BflStaleness.FingerprintOf(second))); } + [Test] + public void WithGenericCounterpartsTheFingerprintStaysAsItWasBeforeTheSimulation() + { + var withoutSimulation = new BflStepFormData(); + withoutSimulation.Set(BflFieldCatalog.TEST_NOTES, "Text"); + var generic = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.GENERIC, People = ["Eva Klein"] } }; + generic.Set(BflFieldCatalog.TEST_NOTES, "Text"); + + // How the lab computed the fingerprint of this form before it simulated people: + var before = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes($"{BflFieldCatalog.TEST_NOTES}\0Text\0"))); + + using (Assert.EnterMultipleScope()) + { + Assert.That(BflStaleness.FingerprintOf(withoutSimulation), Is.EqualTo(before)); + Assert.That(BflStaleness.FingerprintOf(generic), Is.EqualTo(before)); + } + } + + [Test] + public void TheFingerprintCountsTheModeAndThePeopleOfTheSimulation() + { + var empty = new BflStepFormData(); + var single = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.SINGLE_PERSON, People = ["Eva Klein"] } }; + var panel = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.PANEL_DISCUSSION, People = ["Eva Klein"] } }; + var otherPerson = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.SINGLE_PERSON, People = ["Frank Ott"] } }; + + var fingerprints = new[] { empty, single, panel, otherPerson }.Select(BflStaleness.FingerprintOf).ToList(); + + Assert.That(fingerprints, Is.Unique); + } + + [Test] + public void TheOrderAndTheSpellingOfTheSimulatedPeopleIsNoChange() + { + var first = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.PANEL_DISCUSSION, People = ["Eva Klein", "Frank Ott"] } }; + var second = new BflStepFormData { Simulation = new() { Mode = BflSimulationMode.PANEL_DISCUSSION, People = ["frank ott", "Eva Klein"] } }; + + Assert.That(BflStaleness.FingerprintOf(first), Is.EqualTo(BflStaleness.FingerprintOf(second))); + } + [Test] public void AChangedCardSortWeighsLikeChangedDetails() {