diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJsonContext.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJsonContext.cs
index 07792aaed..df9225826 100644
--- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJsonContext.cs
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabJsonContext.cs
@@ -12,6 +12,8 @@ namespace AIStudio.Assistants.BeyondFactsLab;
[JsonSerializable(typeof(BflWorkState))]
[JsonSerializable(typeof(BflResult))]
[JsonSerializable(typeof(BflResearch))]
+[JsonSerializable(typeof(BflPersonResearch))]
+[JsonSerializable(typeof(BflParticipantSelection))]
// The value deck, which comes with the app:
[JsonSerializable(typeof(BflValueDeck))]
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.PersonResearch.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.PersonResearch.cs
new file mode 100644
index 000000000..39e9379a6
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.PersonResearch.cs
@@ -0,0 +1,234 @@
+using System.Text.Json;
+
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+public sealed partial class BeyondFactsLabStore
+{
+ ///
+ /// The folder below the research of the step "Strategy" which holds the research on single people.
+ ///
+ private const string PERSON_RESEARCH_DIRECTORY_NAME = "participants";
+
+ ///
+ /// The file below the research of the step "Strategy" which holds whom of the list of participants the lab
+ /// researches in depth.
+ ///
+ private const string PARTICIPANT_SELECTION_FILE_NAME = "participant-selection.json";
+
+ ///
+ /// Loads whom of the list of participants of a preparation the lab researches in depth.
+ ///
+ /// The ID of the preparation.
+ /// Cancels the loading.
+ /// The selection, or null when nobody chose yet.
+ /// The selection is damaged, or a newer version of AI Studio stored it.
+ public async Task LoadParticipantSelectionAsync(Guid projectId, CancellationToken token = default)
+ {
+ await this.InitializeAsync(token);
+ return await this.LoadParticipantSelectionWithoutInitializeAsync(projectId, token);
+ }
+
+ ///
+ /// Stores whom of the list of participants of a preparation the lab researches in depth, in place of the earlier
+ /// selection.
+ ///
+ /// The ID of the preparation.
+ /// The selection.
+ /// Cancels the storing.
+ /// The preparation, with the time of its last change.
+ /// The preparation does not exist.
+ /// The earlier selection is damaged, or a newer version of AI Studio stored it; it stays as it is.
+ public async Task SaveParticipantSelectionAsync(Guid projectId, BflParticipantSelection selection, CancellationToken token = default)
+ {
+ await this.InitializeAsync(token);
+ var gate = this.GetLock(projectId);
+ await gate.WaitAsync(token);
+ try
+ {
+ var project = await this.LoadRequiredWithoutInitializeAsync(projectId, token);
+
+ // A selection which a newer version stored, or which is damaged, stays as it is:
+ await this.LoadParticipantSelectionWithoutInitializeAsync(projectId, token);
+
+ selection.SchemaVersion = BflParticipantSelection.CURRENT_SCHEMA_VERSION;
+ await WriteTextAtomicAsync(this.ParticipantSelectionPath(projectId), JsonSerializer.Serialize(selection, JSON_OPTIONS), token);
+
+ project.ModifiedAtUtc = DateTimeOffset.UtcNow;
+ await this.StoreProjectAsync(project, token);
+ return project;
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ ///
+ /// Loads the research on one person of a preparation.
+ ///
+ /// The ID of the preparation.
+ /// The name of the person.
+ /// Cancels the loading.
+ /// The research, or null when the person was not researched yet.
+ /// The research is damaged, or a newer version of AI Studio stored it.
+ public async Task LoadPersonResearchAsync(Guid projectId, string name, CancellationToken token = default)
+ {
+ await this.InitializeAsync(token);
+ return await this.LoadPersonResearchWithoutInitializeAsync(this.PersonResearchPath(projectId, BflPersonId.Of(name)), token);
+ }
+
+ ///
+ /// Loads the research on every person of a preparation, including people who are no longer on the list of
+ /// participants. A research which cannot be opened is left out and stays as it is.
+ ///
+ /// The ID of the preparation.
+ /// Cancels the loading.
+ /// The research on the people, in no particular order.
+ public async Task> LoadAllPersonResearchAsync(Guid projectId, CancellationToken token = default)
+ {
+ await this.InitializeAsync(token);
+ var directory = this.PersonResearchDirectory(projectId);
+ if (!Directory.Exists(directory))
+ return [];
+
+ var research = new List();
+ foreach (var path in Directory.EnumerateFiles(directory, "*.json"))
+ {
+ // Only the files of people count, named by their IDs:
+ if (!Guid.TryParseExact(Path.GetFileNameWithoutExtension(path), "D", out _))
+ continue;
+
+ try
+ {
+ if (await this.LoadPersonResearchWithoutInitializeAsync(path, token) is { } found)
+ research.Add(found);
+ }
+ catch (InvalidDataException exception)
+ {
+ logger.LogWarning(exception, "Could not open the research on a person of the preparation '{ProjectId}' of the BeyondFacts Lab.", projectId);
+ }
+ }
+
+ return research;
+ }
+
+ ///
+ /// Stores the research on one person of a preparation, in place of the earlier one.
+ ///
+ /// The ID of the preparation.
+ /// The research.
+ /// Cancels the storing.
+ /// The preparation, with the time of its last change.
+ /// The preparation does not exist.
+ /// The earlier research is damaged, or a newer version of AI Studio stored it; it stays as it is.
+ public async Task SavePersonResearchAsync(Guid projectId, BflPersonResearch research, CancellationToken token = default)
+ {
+ await this.InitializeAsync(token);
+ var gate = this.GetLock(projectId);
+ await gate.WaitAsync(token);
+ try
+ {
+ var project = await this.LoadRequiredWithoutInitializeAsync(projectId, token);
+ var path = this.PersonResearchPath(projectId, BflPersonId.Of(research.Name));
+
+ // A research which a newer version stored, or which is damaged, stays as it is:
+ await this.LoadPersonResearchWithoutInitializeAsync(path, token);
+
+ research.SchemaVersion = BflPersonResearch.CURRENT_SCHEMA_VERSION;
+ foreach (var stage in research.Stages)
+ stage.Research.SchemaVersion = BflResearch.CURRENT_SCHEMA_VERSION;
+
+ await WriteTextAtomicAsync(path, JsonSerializer.Serialize(research, JSON_OPTIONS), token);
+
+ project.ModifiedAtUtc = DateTimeOffset.UtcNow;
+ await this.StoreProjectAsync(project, token);
+ return project;
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ ///
+ /// Loads the research on one person from its file.
+ ///
+ /// The file.
+ /// Cancels the loading.
+ /// The research, or null when the file does not exist.
+ /// The research is damaged, belongs to another file, or a newer version of AI Studio stored it.
+ private async Task LoadPersonResearchWithoutInitializeAsync(string path, CancellationToken token)
+ {
+ if (!File.Exists(path))
+ return null;
+
+ BflPersonResearch? research;
+ try
+ {
+ await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true);
+ research = await JsonSerializer.DeserializeAsync(stream, JSON_OPTIONS, token);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
+ {
+ throw new InvalidDataException("This version of AI Studio cannot open the research on a person.", exception);
+ }
+
+ if (research is null || research.SchemaVersion is < 1 or > BflPersonResearch.CURRENT_SCHEMA_VERSION || IsNull(research.Name) || IsNull(research.Stages) || research.Stages.Any(stage => IsNull(stage) || IsNull(stage.Research) || IsBroken(stage.Research)))
+ throw new InvalidDataException("This version of AI Studio cannot open the research on a person.");
+
+ // The file of a person is named by the ID of their name; a research under another name is no research of theirs:
+ if (!string.Equals(Path.GetFileNameWithoutExtension(path), BflPersonId.Of(research.Name).ToString("D"), StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException("The research on a person is stored under the name of another person.");
+
+ return research;
+ }
+
+ ///
+ /// Loads whom of the list of participants the lab researches in depth.
+ ///
+ /// The ID of the preparation.
+ /// Cancels the loading.
+ /// The selection, or null when the file does not exist.
+ /// The selection is damaged, or a newer version of AI Studio stored it.
+ private async Task LoadParticipantSelectionWithoutInitializeAsync(Guid projectId, CancellationToken token)
+ {
+ var path = this.ParticipantSelectionPath(projectId);
+ if (!File.Exists(path))
+ return null;
+
+ BflParticipantSelection? selection;
+ try
+ {
+ await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true);
+ selection = await JsonSerializer.DeserializeAsync(stream, JSON_OPTIONS, token);
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
+ {
+ throw new InvalidDataException("This version of AI Studio cannot open the selection of participants.", exception);
+ }
+
+ if (selection is null || selection.SchemaVersion is < 1 or > BflParticipantSelection.CURRENT_SCHEMA_VERSION || IsNull(selection.Names) || selection.Names.Any(IsNull))
+ throw new InvalidDataException("This version of AI Studio cannot open the selection of participants.");
+
+ return selection;
+ }
+
+ ///
+ /// Gets the file with the selection of participants of a preparation.
+ ///
+ /// The ID of the preparation.
+ private string ParticipantSelectionPath(Guid projectId) => Path.Combine(this.ProjectDirectory(projectId), STEPS_DIRECTORY_NAME, BflStep.STRATEGY.FolderName, RESEARCH_DIRECTORY_NAME, PARTICIPANT_SELECTION_FILE_NAME);
+
+ ///
+ /// Gets the folder with the research on the people of a preparation.
+ ///
+ /// The ID of the preparation.
+ private string PersonResearchDirectory(Guid projectId) => Path.Combine(this.ProjectDirectory(projectId), STEPS_DIRECTORY_NAME, BflStep.STRATEGY.FolderName, RESEARCH_DIRECTORY_NAME, PERSON_RESEARCH_DIRECTORY_NAME);
+
+ ///
+ /// Gets the file with the research on one person.
+ ///
+ /// The ID of the preparation.
+ /// The ID of the person, see BflPersonId.
+ private string PersonResearchPath(Guid projectId, Guid personId) => Path.Combine(this.PersonResearchDirectory(projectId), $"{personId:D}.json");
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Research.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Research.cs
index acd1ae09e..239dd253f 100644
--- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Research.cs
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BeyondFactsLabStore.Research.cs
@@ -110,12 +110,18 @@ public sealed partial class BeyondFactsLabStore
throw new InvalidDataException("This version of AI Studio cannot open the research.", exception);
}
- if (research is null || research.SchemaVersion is < 1 or > BflResearch.CURRENT_SCHEMA_VERSION || IsNull(research.Text) || IsNull(research.Details) || IsNull(research.SearchQueries) || IsNull(research.Pages))
+ if (research is null || IsBroken(research))
throw new InvalidDataException("This version of AI Studio cannot open the research.");
return research;
}
+ ///
+ /// Tells whether a stored research comes from a newer version of AI Studio, or lacks one of its parts.
+ ///
+ /// The research.
+ private static bool IsBroken(BflResearch research) => research.SchemaVersion is < 1 or > BflResearch.CURRENT_SCHEMA_VERSION || IsNull(research.Text) || IsNull(research.Details) || IsNull(research.SearchQueries) || IsNull(research.Pages) || IsNull(research.Participants) || research.Participants.Any(participant => IsNull(participant) || IsNull(participant.Name) || IsNull(participant.Entry));
+
///
/// Gets the file of the research of a preparation on one topic.
///
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs
index 51364b2bd..62d496cd7 100644
--- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflConversationService.cs
@@ -193,7 +193,7 @@ public sealed class BflConversationService(BeyondFactsLabStore store, EmbeddedCh
var valueDeck = await LoadValueDeckAsync(stepLocation);
var shares = await this.ShareMaterialsAsync(stepLocation, parts, forms, workStates, tools, research, rules, valueDeck, language, provider);
var materials = await this.ReadMaterialsAsync(stepLocation.ProjectId, shares);
- return new(BflPromptComposer.Compose(parts, stepLocation.Step, forms, materials, language, workStates, tools, research, rules, valueDeck), this.ImagesOf(stepLocation.ProjectId, shares), rules);
+ return new(BflPromptComposer.Compose(parts, stepLocation.Step, forms, materials, language, workStates, tools, research.Topics, rules, valueDeck, research.People), this.ImagesOf(stepLocation.ProjectId, shares), rules);
case BflOnboardingThreadLocation:
// It never gets the guidelines themselves, but it tells the person that they exist:
@@ -318,10 +318,10 @@ public sealed class BflConversationService(BeyondFactsLabStore store, EmbeddedCh
/// Loads the research a step carries in its prompt: the step "Strategy" all of it, every other step none.
///
/// The step.
- /// The research this version can open, by its topics.
- private async Task> LoadResearchAsync(BflStepThreadLocation location) => location.Step is BflStep.STRATEGY
- ? await store.LoadAllResearchAsync(location.ProjectId)
- : new Dictionary();
+ /// The research this version can open: on the topics, and on the people of the current list.
+ private async Task LoadResearchAsync(BflStepThreadLocation location) => location.Step is BflStep.STRATEGY
+ ? BflStepResearch.Of(await store.LoadAllResearchAsync(location.ProjectId), await store.LoadAllPersonResearchAsync(location.ProjectId))
+ : BflStepResearch.NONE;
///
/// Loads the value deck a step carries in its prompt, with the card sort of the person: the step "Values and
@@ -344,13 +344,13 @@ public sealed class BflConversationService(BeyondFactsLabStore store, EmbeddedCh
/// The forms of the preparation.
/// The work states of the preparation, whose confirmed ones take room in the prompt as well.
/// What keeps the tools of the lab from being offered, which the prompt describes as well.
- /// The research the prompt of the step carries, by its topics.
+ /// The research the prompt of the step carries, on the topics and on people.
/// The guidelines of the organization, which take room in the prompt and in the first message.
/// The value deck with which the prompt of the step shows the card sort, which takes room as well; null for the other steps.
/// The name of the language the model answers in.
/// The provider which answers.
/// The share of each material this version can open, in the order the person added them.
- private async Task> ShareMaterialsAsync(BflStepThreadLocation location, BflPromptParts parts, IReadOnlyDictionary forms, IReadOnlyDictionary workStates, BflResearchAvailability tools, IReadOnlyDictionary research, string organizationRules, BflValueDeck? valueDeck, string language, AIStudio.Settings.Provider provider)
+ private async Task> ShareMaterialsAsync(BflStepThreadLocation location, BflPromptParts parts, IReadOnlyDictionary forms, IReadOnlyDictionary workStates, BflResearchAvailability tools, BflStepResearch research, string organizationRules, BflValueDeck? valueDeck, string language, AIStudio.Settings.Provider provider)
{
var materials = (await store.ListMaterialsAsync(location.ProjectId)).Select(entry => entry.Material).OfType().ToList();
if (materials.Count is 0)
@@ -359,7 +359,7 @@ public sealed class BflConversationService(BeyondFactsLabStore store, EmbeddedCh
var imageShares = BflMaterialBudget.ShareImages([.. materials.Where(material => material.Kind is BflMaterialKind.IMAGE)], ImagesTheModelTakes(provider));
var sentImages = imageShares.Count(share => share.Image is BflImageDelivery.SENT);
- var promptWithoutMaterials = BflPromptComposer.Compose(parts, location.Step, forms, [], language, workStates, tools, research, organizationRules, valueDeck);
+ var promptWithoutMaterials = BflPromptComposer.Compose(parts, location.Step, forms, [], language, workStates, tools, research.Topics, organizationRules, valueDeck, research.People);
var characters = BflMaterialBudget.CharactersFor(provider.GetModelProfile().Context, promptWithoutMaterials.Length + BflOrganizationRules.Frame(organizationRules).Length, sentImages);
var textShares = BflMaterialBudget.Share([.. materials.Where(material => material.Kind is not BflMaterialKind.IMAGE)], characters);
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflIntroduction.razor b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflIntroduction.razor
index 0160ce632..517b024d3 100644
--- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflIntroduction.razor
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflIntroduction.razor
@@ -55,6 +55,9 @@
@string.Format(Culture, TA("In the step '{0}', research agents of the lab look into the event, its participants, and the public debate on its topic, one agent for each. They start on their own once you confirm the event details, provided the web search is set up in AI Studio, and you can start them again at any time."), BflStep.STRATEGY.Title)
@TA("If you state in the event details that the event is not public, the lab never researches it on the web.")
+
+ @TA("From the list of participants, you choose whom the lab researches in depth; for a long list, the lab suggests whom. For this suggestion, it uses your details of the first three steps, but searches nothing, and only names from the list go on into the research. For each person you choose, research agents look into the profile, the public positions on the topic, and how the person argues in public, one stage after the other. They receive only the name and the list entry of the person, the public details about the event, and what the earlier stages found. They look only for public, professional information, keep summaries with sources, and create no personality profiles.")
+
@TA("The research agents receive only the public details about the event, which the form marks with a symbol. Your other details, your material, your work states, and your conversations stay in your preparation and never reach a search. The conversations of the steps never search the web themselves: they only read web pages whose addresses stand in them, e.g., the link to the event.")
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflModelAnswerJson.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflModelAnswerJson.cs
new file mode 100644
index 000000000..9cc5692d1
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflModelAnswerJson.cs
@@ -0,0 +1,24 @@
+using System.Text.Json;
+
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+///
+/// How the BeyondFacts Lab reads the JSON which models write for it.
+///
+internal static class BflModelAnswerJson
+{
+ ///
+ /// The options for the JSON in a model answer. They forgive what models often write, such as trailing commas,
+ /// comments, or another case, but refuse a field named twice: another reader might see its other value.
+ ///
+ internal static readonly JsonSerializerOptions OPTIONS = new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ AllowTrailingCommas = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ RespectNullableAnnotations = true,
+ AllowDuplicateProperties = false,
+ TypeInfoResolver = BflModelAnswerJsonContext.Default,
+ }.AsReadOnly();
+}
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflModelAnswerJsonContext.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflModelAnswerJsonContext.cs
new file mode 100644
index 000000000..3968ec669
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflModelAnswerJsonContext.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+///
+/// The contracts with which System.Text.Json reads the JSON which models write for the BeyondFacts Lab, built at
+/// compile time.
+///
+[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
+[JsonSerializable(typeof(BflParticipantSelectionAnswer))]
+internal sealed partial class BflModelAnswerJsonContext : JsonSerializerContext;
\ No newline at end of file
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflParticipant.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflParticipant.cs
new file mode 100644
index 000000000..bcc9dc276
--- /dev/null
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflParticipant.cs
@@ -0,0 +1,130 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace AIStudio.Assistants.BeyondFactsLab;
+
+///
+/// A person whom the research on the participants of the BeyondFacts Lab lists as taking part in the event.
+///
+///
+/// The agent of the topic "Participants" gives every person a heading of the third level with nothing but the name, see
+/// Prompts/research/participants.md. The lab reads these headings once, when it stores the research, and keeps the list
+/// as it is from then on, see BflResearch.Participants.
+///
+public sealed partial class BflParticipant
+{
+ ///
+ /// How many characters a name has at most. A longer heading is no name.
+ ///
+ public const int MAX_NAME_CHARACTERS = 200;
+
+ ///
+ /// How many people a list holds at most. The prompt asks for 40 at most; the rest is left out.
+ ///
+ public const int MAX_PARTICIPANTS = 50;
+
+ ///
+ /// Gets or sets the name of the person, as the heading gives it.
+ ///
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets what the list says about the person below the heading, in Markdown: the role at the event, the
+ /// institution or function, and the source.
+ ///
+ public string Entry { get; set; } = string.Empty;
+
+ ///
+ /// Tells whether this is the person with a name, regardless of case and of the spaces between the words.
+ ///
+ /// The name.
+ public bool HasName(string name) => string.Equals(NameKey(this.Name), NameKey(name), StringComparison.Ordinal);
+
+ ///
+ /// Reads the people from the answer of the agent of the topic "Participants".
+ ///
+ ///
+ /// Every heading of the third level starts a person; a heading of a higher level ends it, one of a lower level
+ /// belongs to its entry. Markdown in a heading, such as emphasis or a link, is taken off. A heading which is empty
+ /// or too long is no person, and a name which comes twice counts once, with its first entry.
+ ///
+ /// The answer, with its lines ending in LF.
+ /// The people, in the order of the answer.
+ public static IReadOnlyList ListIn(string text)
+ {
+ var participants = new List();
+ var keys = new HashSet(StringComparer.Ordinal);
+ BflParticipant? current = null;
+ var entry = new StringBuilder();
+
+ foreach (var line in text.ReplaceLineEndings("\n").Split('\n'))
+ {
+ var heading = HeadingPattern().Match(line);
+ if (!heading.Success || heading.Groups["level"].Length > 3)
+ {
+ if (current is not null)
+ entry.Append(line).Append('\n');
+
+ continue;
+ }
+
+ Finish();
+ if (heading.Groups["level"].Length < 3 || participants.Count >= MAX_PARTICIPANTS)
+ continue;
+
+ var name = CleanName(heading.Groups["text"].Value);
+ if (name.Length is 0 or > MAX_NAME_CHARACTERS || !keys.Add(NameKey(name)))
+ continue;
+
+ current = new BflParticipant { Name = name };
+ participants.Add(current);
+ }
+
+ Finish();
+ return participants;
+
+ void Finish()
+ {
+ if (current is not null)
+ current.Entry = entry.ToString().Trim();
+
+ current = null;
+ entry.Clear();
+ }
+ }
+
+ ///
+ /// Gets what decides whether two names are the same: the words in capitals, separated by one space.
+ ///
+ /// The name.
+ public static string NameKey(string name) => string.Join(' ', name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToUpperInvariant();
+
+ ///
+ /// Takes the Markdown off a heading and joins its words with single spaces.
+ ///
+ /// The text of the heading.
+ private static string CleanName(string heading)
+ {
+ var cleaned = MarkdownLinkPattern().Replace(heading, "${label}");
+ cleaned = MarkupPattern().Replace(cleaned, string.Empty);
+ return string.Join(' ', cleaned.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
+ }
+
+ ///
+ /// A heading in Markdown, with its level and its text.
+ ///
+ [GeneratedRegex(@"^[ \t]{0,3}(?#{1,6})[ \t]+(?.*?)[ \t#]*$", RegexOptions.CultureInvariant)]
+ private static partial Regex HeadingPattern();
+
+ ///
+ /// A link in Markdown, whose text stays.
+ ///
+ [GeneratedRegex(@"\[(?
public const string RESEARCH_PATH = RESEARCH_FOLDER + "research.md";
+ ///
+ /// The prompt every agent of the research on a person starts with: its role, its rules, the person, what earlier
+ /// stages found, and the public details about the event.
+ ///
+ public const string PERSON_RESEARCH_PATH = RESEARCH_FOLDER + "person.md";
+
+ ///
+ /// The prompt with which the lab suggests whom of a long list of participants to research. It knows the details of
+ /// the preparation, so its request has no tools.
+ ///
+ public const string PARTICIPANT_SELECTION_PATH = RESEARCH_FOLDER + "participant-selection.md";
+
///
/// The instruction to address the person formally.
///
@@ -128,6 +140,31 @@ public static class BflPromptLibrary
BflPromptTemplate.TOOL_CALLS,
BflPromptTemplate.PUBLIC_DETAILS);
+ ///
+ /// The placeholders BflPromptComposer fills in the prompt of the agents of the research on a person.
+ ///
+ ///
+ /// Nothing but public content: the public details about the event, the entry of the person in the list of
+ /// participants, and what earlier stages found on the web. No placeholder brings the material, the work states, or
+ /// any other detail of a preparation into a research.
+ ///
+ private static readonly FrozenSet PERSON_RESEARCH_PLACEHOLDERS = FrozenSet.Create(StringComparer.Ordinal,
+ BflPromptTemplate.RESPONSE_LANGUAGE,
+ BflPromptTemplate.TOPIC,
+ BflPromptTemplate.TOOL_CALLS,
+ BflPromptTemplate.PERSON,
+ BflPromptTemplate.EARLIER_STAGES,
+ BflPromptTemplate.PUBLIC_DETAILS);
+
+ ///
+ /// The placeholders BflPromptComposer fills in the prompt which suggests whom to research: the details of the
+ /// preparation and the list of participants.
+ ///
+ private static readonly FrozenSet PARTICIPANT_SELECTION_PLACEHOLDERS = FrozenSet.Create(StringComparer.Ordinal,
+ BflPromptTemplate.SELECTION_SIZE,
+ BflPromptTemplate.DETAILS,
+ BflPromptTemplate.PARTICIPANTS);
+
///
/// The placeholders of every other file: none, because BflPromptComposer inserts these files as values.
///
@@ -160,6 +197,12 @@ public static class BflPromptLibrary
/// The topic of the research.
public static string ResearchTopicPath(BflResearchTopic topic) => $"{RESEARCH_FOLDER}{topic.FileStem}.md";
+ ///
+ /// Gets the task of the agent of a stage of the research on a person.
+ ///
+ /// The stage of the research.
+ public static string PersonResearchStagePath(BflPersonResearchStage stage) => $"{RESEARCH_FOLDER}person-{stage.FileStem}.md";
+
///
/// Gets the instruction for a form of address.
///
@@ -178,6 +221,9 @@ public static class BflPromptLibrary
..BflResultKind.All.Where(kind => kind.IsWrittenByModel).Select(ResultPath),
RESEARCH_PATH,
..BflResearchTopic.All.Select(ResearchTopicPath),
+ PERSON_RESEARCH_PATH,
+ ..BflPersonResearchStage.All.Select(PersonResearchStagePath),
+ PARTICIPANT_SELECTION_PATH,
..Enum.GetValues().Select(AddressPath).Distinct(),
ORGANIZATION_RULES_PATH,
ONBOARDING_ORGANIZATION_RULES_PATH,
@@ -196,8 +242,9 @@ public static class BflPromptLibrary
///
///
/// BflPromptComposer fills placeholders in the core, in the prompt of the onboarding, in the requests for a work
- /// state and for a result, and in the prompt of the research agents only. It inserts every other file as a value,
- /// whose placeholders it leaves as they are, so that they would reach the model as text.
+ /// state and for a result, in the prompts of the research agents on topics and on people, and in the prompt which
+ /// suggests whom to research only. It inserts every other file as a value, whose placeholders it leaves as they
+ /// are, so that they would reach the model as text.
///
/// The file, one of the paths of this class.
public static IReadOnlySet PlaceholdersOf(string path) => path switch
@@ -206,6 +253,8 @@ public static class BflPromptLibrary
ONBOARDING_PATH => ONBOARDING_PLACEHOLDERS,
WORK_STATE_PATH => WORK_STATE_PLACEHOLDERS,
RESEARCH_PATH => RESEARCH_PLACEHOLDERS,
+ PERSON_RESEARCH_PATH => PERSON_RESEARCH_PLACEHOLDERS,
+ PARTICIPANT_SELECTION_PATH => PARTICIPANT_SELECTION_PLACEHOLDERS,
_ when path.StartsWith(RESULTS_FOLDER, StringComparison.Ordinal) => RESULT_PLACEHOLDERS,
_ => NO_PLACEHOLDERS
};
@@ -254,6 +303,19 @@ public static class BflPromptLibrary
await ReadAsync(RESEARCH_PATH, overrideDirectory, token),
await ReadAsync(ResearchTopicPath(topic), overrideDirectory, token));
+ ///
+ /// Reads the texts of the system prompt of an agent of the research on a person.
+ ///
+ /// The stage of the research.
+ /// The folder whose files replace the prompts, or null while the person does not allow it.
+ /// The cancellation token.
+ /// The texts, which BflPromptComposer puts together; the task of the stage is the topic.
+ /// The app comes without one of the files.
+ /// A file which replaces a prompt contains a placeholder which the lab does not fill in it.
+ public static async Task LoadPersonResearchPartsAsync(BflPersonResearchStage stage, string? overrideDirectory = null, CancellationToken token = default) => new(
+ await ReadAsync(PERSON_RESEARCH_PATH, overrideDirectory, token),
+ await ReadAsync(PersonResearchStagePath(stage), overrideDirectory, token));
+
///
/// Reads a file of the lab's prompts: the file which replaces it, if there is one, or else the file which comes
/// with the app.
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptTemplate.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptTemplate.cs
index ad45f1918..8c99df6ce 100644
--- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptTemplate.cs
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflPromptTemplate.cs
@@ -99,6 +99,26 @@ public static partial class BflPromptTemplate
///
public const string PUBLIC_DETAILS = "PUBLIC_DETAILS";
+ ///
+ /// The person an agent of the research on people looks into: the name and the entry in the list of participants.
+ ///
+ public const string PERSON = "PERSON";
+
+ ///
+ /// What the earlier stages of the research on a person found.
+ ///
+ public const string EARLIER_STAGES = "EARLIER_STAGES";
+
+ ///
+ /// The list of participants from which the lab suggests whom to research.
+ ///
+ public const string PARTICIPANTS = "PARTICIPANTS";
+
+ ///
+ /// How many people the lab suggests to research at most.
+ ///
+ public const string SELECTION_SIZE = "SELECTION_SIZE";
+
///
/// Fills every placeholder of a template.
///
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflResearch.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflResearch.cs
index 0273c2d93..84a61009f 100644
--- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflResearch.cs
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflResearch.cs
@@ -17,7 +17,7 @@ public sealed class BflResearch
///
/// The version of the stored form this version of AI Studio writes. Raise it with every new property.
///
- public const int CURRENT_SCHEMA_VERSION = 1;
+ public const int CURRENT_SCHEMA_VERSION = 2;
///
/// How many characters of the answer of the agent a research keeps at most, since the text goes into the prompt of
@@ -65,6 +65,13 @@ public sealed class BflResearch
///
public List Pages { get; set; } = [];
+ ///
+ /// Gets or sets the people the research on the participants lists, read from its text when it was stored, see
+ /// BflParticipant.ListIn. Empty for the other topics, and for a research stored before the lab kept the list
+ /// (version 1 of the stored form).
+ ///
+ public List Participants { get; set; } = [];
+
///
/// Gets or sets when the agent finished.
///
@@ -90,12 +97,13 @@ public sealed class BflResearch
/// The sources the tools reported, for the titles of the pages.
/// The public details the agent received.
/// When the agent finished.
+ /// How many characters of the answer the research keeps at most.
/// The research; its text is empty when the agent wrote nothing.
- public static BflResearch Of(string answer, IEnumerable invocations, IEnumerable sources, IReadOnlyList details, DateTimeOffset createdAtUtc)
+ public static BflResearch Of(string answer, IEnumerable invocations, IEnumerable sources, IReadOnlyList details, DateTimeOffset createdAtUtc, int maxTextCharacters = MAX_TEXT_CHARACTERS)
{
var text = BflResultText.Read(answer);
- if (text.Length > MAX_TEXT_CHARACTERS)
- text = BflMaterialBudget.EndAtLineEnd(text[..MAX_TEXT_CHARACTERS]);
+ if (text.Length > maxTextCharacters)
+ text = BflMaterialBudget.EndAtLineEnd(text[..maxTextCharacters]);
var executed = invocations.Where(invocation => invocation.WasExecuted).OrderBy(invocation => invocation.Order).ToList();
var queries = executed
diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflResearchPanel.razor b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflResearchPanel.razor
index 9142bb3cb..e7ea18c07 100644
--- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflResearchPanel.razor
+++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflResearchPanel.razor
@@ -1,20 +1,20 @@
@inherits MSGComponentBase
@T("Research")
-@TA("Research agents of the lab look into the event, its participants, and the public debate on its topic, one agent for each. The conversation of this step builds on what they find.")
+@TA("Research agents of the lab look into the event, its participants, and the public debate on its topic, one agent for each. The conversation of this step builds on what they find.")
@if (this.IsNotPublic)
{
@* An event which is not public is never researched, and earlier research no longer counts: *@
- @string.Format(Culture, TA("The event is not public. Therefore, the lab does not research it on the web, and none of your details reach a search. If the event is public after all, change this in the step '{0}'."), BflStep.FRAMEWORK.Title)
+ @string.Format(Culture, TA("The event is not public. Therefore, the lab does not research it on the web, and none of your details reach a search. If the event is public after all, change this in the step '{0}'."), BflStep.FRAMEWORK.Title)
}
else
{
@* What the research receives, and what it never receives: *@
- @TA("The research agents receive only the public details about the event. Your other details, your material, your work states, and your conversations stay in your preparation and never reach a search.")
+ @TA("The research agents receive only the public details about the event. Your other details, your material, your work states, and your conversations stay in your preparation and never reach a search.")
@if (this.PublicDetails.Count > 0)
{
@T("The research receives these details:")
@@ -27,17 +27,21 @@ else
}
else
{
- @string.Format(Culture, TA("You have not entered any public details yet. Enter them in the step '{0}', such as the topic of the event or its link."), BflStep.FRAMEWORK.Title)
+ @string.Format(Culture, TA("You have not entered any public details yet. Enter them in the step '{0}', such as the topic of the event or its link."), BflStep.FRAMEWORK.Title)
}
@if (this.ProviderIssue is { } providerIssue)
{
- @providerIssue
+
+ @providerIssue
+
}
else if (this.availability is { CanResearch: false } unavailable)
{
- @unavailable.Message
+
+ @unavailable.Message
+
}
}
- @if (found is null)
+ @* While the agent researches anew, the earlier research would look like its result: *@
+ @if (!isRunning && found is null)
{
- @topic.Description
+ @topic.Description
}
- else
+ else if (!isRunning && found is not null)
{
@if (found.IsOutdated(this.PublicDetails))
{
- @TA("The public details changed since this research. You can research this topic again.")
+ @TA("The public details changed since this research. You can research this topic again.")
}
@@ -101,47 +106,27 @@ else
- @* What left AI Studio for this research: *@
- @T("Queries sent to the web search")
- @if (found.SearchQueries.Count is 0)
- {
- @T("None")
- }
- else
- {
-