Added the BeyondFacts Lab as a prototype (#1175)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-08 19:49:46 +01:00
1 parent db8dae1cf6
commit b6c3fbb5db
90 files changed
+5936 -209

No files matched your search

@@ -0,0 +1,64 @@
using System.Text;
using Microsoft.Extensions.FileProviders;
#if RELEASE
using System.Reflection;
#endif
namespace AIStudio.Tools;
/// <summary>
/// Reads files which come with AI Studio, such as prompts, guides, or the built-in plugins.
/// </summary>
/// <remarks>
/// The files lie below the project folder and are listed as EmbeddedResource in the project file. A development
/// build reads them from the disk, so that a change shows without building again; a release reads them from the
/// resources embedded into the app. Paths are relative to the project folder and use forward slashes.
/// </remarks>
public static class AppResources
{
/// <summary>
/// Gets a file provider for a folder of the app.
/// </summary>
/// <param name="relativeFolder">The folder, relative to the project folder, e.g., "Plugins".</param>
/// <returns>The file provider, whose paths are relative to that folder.</returns>
public static IFileProvider FolderProvider(string relativeFolder)
{
var folder = Normalize(relativeFolder);
#if DEBUG
return new PhysicalFileProvider(Path.Join(Environment.CurrentDirectory, folder));
#else
return new ManifestEmbeddedFileProvider(Assembly.GetAssembly(typeof(Program))!, folder);
#endif
}
/// <summary>
/// Reads a text file of the app.
/// </summary>
/// <param name="relativePath">The file, relative to the project folder, e.g., "Assistants/PromptOptimizer/prompting_guideline.md".</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The text, or an empty string when the app has no such file.</returns>
public static async Task<string> ReadTextAsync(string relativePath, CancellationToken token = default)
{
var path = Normalize(relativePath);
#if DEBUG
var filePath = Path.Join(Environment.CurrentDirectory, path);
return File.Exists(filePath) ? await File.ReadAllTextAsync(filePath, Encoding.UTF8, token) : string.Empty;
#else
var file = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(typeof(Program))!).GetFileInfo(path);
if (!file.Exists || file.IsDirectory)
return string.Empty;
await using var stream = file.CreateReadStream();
using var reader = new StreamReader(stream, Encoding.UTF8);
return await reader.ReadToEndAsync(token);
#endif
}
/// <summary>
/// Turns a path into the form both kinds of builds understand: forward slashes, without a slash at either end.
/// </summary>
/// <param name="relativePath">The path.</param>
private static string Normalize(string relativePath) => relativePath.Replace('\\', '/').Trim('/');
}
@@ -72,6 +72,7 @@ public static class AssistantVisibilityExtensions
Components.BATCH_PROCESSING_ASSISTANT => ConfigurableAssistant.BATCH_PROCESSING_ASSISTANT,
Components.SLIDE_BUILDER_ASSISTANT => ConfigurableAssistant.SLIDE_BUILDER_ASSISTANT,
Components.VISUAL_BRIEFING_ASSISTANT => ConfigurableAssistant.VISUAL_BRIEFING_ASSISTANT,
Components.BEYOND_FACTS_LAB_ASSISTANT => ConfigurableAssistant.BEYOND_FACTS_LAB_ASSISTANT,
Components.I18N_ASSISTANT => ConfigurableAssistant.I18N_ASSISTANT,
Components.LOG_VIEWER_ASSISTANT => ConfigurableAssistant.LOG_VIEWER_ASSISTANT,
@@ -43,4 +43,5 @@ public enum Components
LOG_VIEWER_ASSISTANT,
VISUAL_BRIEFING_ASSISTANT,
BATCH_PROCESSING_ASSISTANT,
BEYOND_FACTS_LAB_ASSISTANT,
}
@@ -22,6 +22,7 @@ public static class ComponentsExtensions
public PreviewFeatures RequiredPreviewFeature => component switch
{
Components.VISUAL_BRIEFING_ASSISTANT => PreviewFeatures.PRE_VISUAL_BRIEFING_ASSISTANT_2026,
Components.BEYOND_FACTS_LAB_ASSISTANT => PreviewFeatures.PRE_BEYOND_FACTS_LAB_2026,
_ => PreviewFeatures.NONE,
};
@@ -33,15 +34,16 @@ public static class ComponentsExtensions
/// <remarks>
/// Components return <c>false</c> for three different reasons. The chat has no assistant sessions
/// at all. Dynamic assistants key their sessions per plugin. The visual briefing assistant keys
/// its sessions per briefing, so it owns one slot per stored briefing rather than one per
/// component. All must be excluded from the single-slot checks, which is why this is a
/// capability and not a component comparison.
/// its sessions per briefing, and the BeyondFacts Lab per step of a preparation, so they own one
/// slot per stored item rather than one per component. All must be excluded from the single-slot
/// checks, which is why this is a capability and not a component comparison.
/// </remarks>
/// <value><c>true</c> when the component owns exactly one session slot.</value>
public bool HasSingleSessionSlot => component switch
{
Components.CHAT or Components.DYNAMIC_ASSISTANT => false,
Components.VISUAL_BRIEFING_ASSISTANT => false,
Components.BEYOND_FACTS_LAB_ASSISTANT => false,
_ => true,
};
@@ -68,7 +70,10 @@ public static class ComponentsExtensions
Components.DOCUMENT_ANALYSIS_ASSISTANT => false,
Components.BATCH_PROCESSING_ASSISTANT => false,
Components.LOG_VIEWER_ASSISTANT => false,
// The lab takes in nothing from other places, and its answers stay in its own steps:
Components.BEYOND_FACTS_LAB_ASSISTANT => false,
Components.APP_SETTINGS => false,
Components.WRITER => false,
@@ -105,7 +110,8 @@ public static class ComponentsExtensions
Components.VISUAL_BRIEFING_ASSISTANT => TB("Visual Briefing Assistant"),
Components.META_ASSISTANT => TB("Assistant Builder"),
Components.LOG_VIEWER_ASSISTANT => TB("Log Viewer Assistant"),
Components.BEYOND_FACTS_LAB_ASSISTANT => TB("BeyondFacts Lab"),
Components.CHAT => TB("New Chat"),
_ => Enum.GetName(component)!,
@@ -157,7 +163,10 @@ public static class ComponentsExtensions
Components.SLIDE_BUILDER_ASSISTANT => settingsManager.ConfigurationData.SlideBuilder.PreselectOptions ? settingsManager.ConfigurationData.SlideBuilder.MinimumProviderConfidence : default,
Components.META_ASSISTANT => settingsManager.ConfigurationData.AssistantBuilder.PreselectOptions ? settingsManager.ConfigurationData.AssistantBuilder.MinimumProviderConfidence : default,
Components.VISUAL_BRIEFING_ASSISTANT => settingsManager.ConfigurationData.VisualBriefing.MinimumProviderConfidence,
// Unpublished research is the usual material of the lab, so its minimum never drops below the floor:
Components.BEYOND_FACTS_LAB_ASSISTANT => settingsManager.ConfigurationData.BeyondFactsLab.MinimumProviderConfidence > DataBeyondFactsLab.MINIMUM_CONFIDENCE_FLOOR ? settingsManager.ConfigurationData.BeyondFactsLab.MinimumProviderConfidence : DataBeyondFactsLab.MINIMUM_CONFIDENCE_FLOOR,
// The minimum confidence for the Document Analysis Assistant is set per policy.
// We do this inside the Document Analysis Assistant component:
Components.DOCUMENT_ANALYSIS_ASSISTANT => ConfidenceLevel.NONE,
@@ -191,6 +200,7 @@ public static class ComponentsExtensions
Components.SLIDE_BUILDER_ASSISTANT => settingsManager.ConfigurationData.SlideBuilder.PreselectOptions ? settingsManager.GetProviderById(settingsManager.ConfigurationData.SlideBuilder.PreselectedProvider) : Settings.Provider.NONE,
Components.META_ASSISTANT => settingsManager.ConfigurationData.AssistantBuilder.PreselectOptions ? settingsManager.GetProviderById(settingsManager.ConfigurationData.AssistantBuilder.PreselectedProvider) : Settings.Provider.NONE,
Components.VISUAL_BRIEFING_ASSISTANT => settingsManager.GetProviderById(settingsManager.ConfigurationData.VisualBriefing.PreselectedProvider),
Components.BEYOND_FACTS_LAB_ASSISTANT => settingsManager.GetProviderById(settingsManager.ConfigurationData.BeyondFactsLab.PreselectedProvider),
// The Document Analysis Assistant does not have a preselected provider at the component level.
// The provider is selected per policy instead. We do this inside the Document Analysis Assistant component.
@@ -229,6 +239,9 @@ public static class ComponentsExtensions
// The profile is selected per policy instead. We do this inside the Document Analysis Assistant component:
Components.DOCUMENT_ANALYSIS_ASSISTANT => Profile.NO_PROFILE.Id,
// The lab works with what the person enters in each preparation and never with a profile:
Components.BEYOND_FACTS_LAB_ASSISTANT => Profile.NO_PROFILE.Id,
_ => string.Empty,
};
@@ -45,6 +45,7 @@ public static class DataBackupContents
new(DataBackupLocation.DATA_DIRECTORY, PLUGINS_FOLDER, DataBackupEntryKind.PLUGINS),
new(DataBackupLocation.DATA_DIRECTORY, "tokenizers/", DataBackupEntryKind.TOKENIZERS),
new(DataBackupLocation.DATA_DIRECTORY, "visualBriefings/", DataBackupEntryKind.VISUAL_BRIEFINGS),
new(DataBackupLocation.DATA_DIRECTORY, "beyondFactsLab/", DataBackupEntryKind.BEYOND_FACTS_LAB),
new(DataBackupLocation.DATA_DIRECTORY, "databases/sqlite/", DataBackupEntryKind.DATA_SOURCE_INDEX),
new(DataBackupLocation.DATA_DIRECTORY, "databases/vector_database/stores/", DataBackupEntryKind.VECTOR_STORES),
new(DataBackupLocation.DATA_DIRECTORY, "mediaTranscripts/", DataBackupEntryKind.MEDIA_TRANSCRIPTS),
@@ -18,6 +18,7 @@ public sealed record DataBackupEntry(DataBackupLocation Location, string Relativ
or DataBackupEntryKind.PLUGINS
or DataBackupEntryKind.TOKENIZERS
or DataBackupEntryKind.VISUAL_BRIEFINGS
or DataBackupEntryKind.BEYOND_FACTS_LAB
or DataBackupEntryKind.DATA_SOURCE_INDEX
or DataBackupEntryKind.VECTOR_STORES
or DataBackupEntryKind.MEDIA_TRANSCRIPTS;
@@ -41,6 +41,11 @@ public enum DataBackupEntryKind
/// </summary>
VISUAL_BRIEFINGS,
/// <summary>
/// The preparations of the BeyondFacts Lab.
/// </summary>
BEYOND_FACTS_LAB,
/// <summary>
/// The SQLite index of the data sources: which files are embedded, and their text chunks.
/// </summary>
@@ -19,7 +19,7 @@ public static class ILangExtensions
typeName = typeName[..typeName.IndexOf('`')];
var ns = $"{typeNamespace}::{typeName}".ToUpperInvariant().Replace(".", "::");
var key = $"root::{ns}::T{fallbackEN.ToFNV32()}";
var key = $"root::{ns}::T{I18NKeyHash.Of(fallbackEN)}";
if(plugin is NoPluginLanguage)
return fallbackEN;
@@ -516,6 +516,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.OptimizeImages, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.VisualBriefing, x => x.MinimumProviderConfidence, this.Id, settingsTable, dryRun);
// Config: BeyondFacts Lab defaults?
ManagedConfiguration.TryProcessConfiguration(x => x.BeyondFactsLab, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BeyondFactsLab, x => x.MinimumProviderConfidence, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BeyondFactsLab, x => x.AddressForm, this.Id, settingsTable, dryRun);
// Config: Assistant Builder defaults?
ManagedConfiguration.TryProcessConfiguration(x => x.AssistantBuilder, x => x.PreselectOptions, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.AssistantBuilder, x => x.PreselectedProvider, Guid.Empty, this.Id, settingsTable, dryRun);
@@ -1,9 +1,5 @@
using Microsoft.Extensions.FileProviders;
#if RELEASE
using System.Reflection;
#endif
namespace AIStudio.Tools.PluginSystem;
public static partial class PluginFactory
@@ -48,13 +44,7 @@ public static partial class PluginFactory
{
try
{
#if DEBUG
var basePath = Path.Join(Environment.CurrentDirectory, "Plugins");
var resourceFileProvider = new PhysicalFileProvider(basePath);
#else
var resourceFileProvider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "Plugins");
#endif
var resourceFileProvider = AppResources.FolderProvider("Plugins");
var metaData = plugin.MetaData();
var mainResourcePath = $"{metaData.ResourcePath}/plugin.lua";
var resourceInfo = resourceFileProvider.GetFileInfo(mainResourcePath);
@@ -1,7 +1,3 @@
// ReSharper disable RedundantUsingDirective
using System.Reflection;
using Microsoft.Extensions.FileProviders;
// ReSharper restore RedundantUsingDirective
using System.Text;
using System.Text.Json;
using AIStudio.Assistants.Builder;
@@ -255,7 +251,7 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry,
foreach (var contextFile in ASSISTANT_CONTEXT_FILES)
{
var content = await ReadAppResourceTextAsync(contextFile.RelativePath);
var content = await AppResources.ReadTextAsync(contextFile.RelativePath);
if (string.IsNullOrWhiteSpace(content))
{
logger.LogError($"The context for \"{contextFile.Title}\" could not be read from the assembly. Path: {contextFile.RelativePath}");
@@ -713,7 +709,7 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry,
private async Task<string> LoadLuaResponseSchemaAsync()
{
var responseSchema = await ReadAppResourceTextAsync(LUA_RESPONSE_SCHEMA_PATH);
var responseSchema = await AppResources.ReadTextAsync(LUA_RESPONSE_SCHEMA_PATH);
if (!string.IsNullOrWhiteSpace(responseSchema))
return responseSchema.Trim();
@@ -743,26 +739,6 @@ public sealed class AssistantPluginGenerationService(ToolRegistry toolRegistry,
return builder.ToString().Trim();
}
private static async Task<string> ReadAppResourceTextAsync(string relativePath)
{
relativePath = relativePath.Replace('\\', '/');
#if DEBUG
var filePath = Path.Join(Environment.CurrentDirectory, relativePath);
return File.Exists(filePath)
? await File.ReadAllTextAsync(filePath)
: string.Empty;
#else
var provider = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!);
var file = provider.GetFileInfo(relativePath);
if (!file.Exists)
return string.Empty;
await using var stream = file.CreateReadStream();
using var reader = new StreamReader(stream, Encoding.UTF8);
return await reader.ReadToEndAsync();
#endif
}
private static bool ProviderIsUsable(ProviderSettings provider) => provider != ProviderSettings.NONE && provider.UsedLLMProvider is not LLMProviders.NONE;
private static bool IsValidChatLaunchRequest(AssistantBuilderChatLaunchRequest? launch)