mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 17:27:21 +00:00
Added a Send to menu to every chat message and kept restrictions when sending content on (#1124)
This commit is contained in:
1 parent
d06688ca08
commit
bf54b8cd0d
73 files changed
+1923
-390
No files matched your search
@@ -0,0 +1,221 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.Web;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the restrictions content carries out of a chat, and how another thread takes them over.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Content which leaves a chat, such as a message sent to an assistant, must demand there what it
|
||||
/// demanded in the chat: an answer written from mails must not reach a provider or a tool there which
|
||||
/// the chat would have kept from it. Each part only ever tightens, wherever the content goes.<br/><br/>
|
||||
///
|
||||
/// The checks of a provider reach the settings through Program.SERVICE_PROVIDER, as in the app.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ChatRestrictionsTests
|
||||
{
|
||||
private const string DATA_SOURCE_ID = "3e8b1d6f-9a2c-4f7e-b5d1-6c4a9e2f8b17";
|
||||
private const string MAILBOX_ID = "5c9e2a7b-3f1d-4e8a-b6c4-2d7f9a1e3b5c";
|
||||
private const string CLOUD_PROVIDER_ID = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SELF_HOSTED_PROVIDER_ID = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private SettingsManager settingsManager = null!;
|
||||
private ServiceProvider serviceProvider = null!;
|
||||
private IServiceProvider previousServiceProvider = null!;
|
||||
|
||||
[SetUp]
|
||||
public void CreateSettings()
|
||||
{
|
||||
this.settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
|
||||
|
||||
this.previousServiceProvider = Program.SERVICE_PROVIDER;
|
||||
this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).BuildServiceProvider();
|
||||
Program.SERVICE_PROVIDER = this.serviceProvider;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RestoreApplicationState()
|
||||
{
|
||||
Program.SERVICE_PROVIDER = this.previousServiceProvider;
|
||||
this.serviceProvider.Dispose();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheStricterOfEachPartWins()
|
||||
{
|
||||
var fromDataSource = new ChatRestrictions(ConfidenceLevel.HIGH, DataSourceSecurity.ALLOW_ANY, new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.DATA_SOURCE, DATA_SOURCE_ID), []).WithWebAddressesWrittenByModelIn("See https://example.org/report.");
|
||||
var fromMailbox = new ChatRestrictions(ConfidenceLevel.LOW, DataSourceSecurity.SELF_HOSTED, new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, MAILBOX_ID), []).WithWebAddressesWrittenByModelIn("See https://example.org/minutes.");
|
||||
|
||||
foreach (var combined in new[] { fromDataSource.StricterOf(fromMailbox), fromMailbox.StricterOf(fromDataSource) })
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(combined.ProviderConfidence, Is.EqualTo(ConfidenceLevel.HIGH));
|
||||
Assert.That(combined.DataSecurity, Is.EqualTo(DataSourceSecurity.SELF_HOSTED), "Data for self-hosted providers only keeps them as the only choice, whichever side brings it.");
|
||||
Assert.That(combined.OutboundData.Restriction, Is.EqualTo(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES));
|
||||
Assert.That(combined.OutboundData.Reference, Is.EqualTo(MAILBOX_ID), "The source which demands the strictest restriction is the one to name.");
|
||||
Assert.That(combined.WebAddressesWrittenByModels, Is.EquivalentTo(new[] { RequestKey("https://example.org/report"), RequestKey("https://example.org/minutes") }), "What models wrote on either side never counts as given.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheAddressesInATextAModelWroteAreKeptAsTheServerGetsAskedForThem()
|
||||
{
|
||||
var restrictions = ChatRestrictions.NONE.WithWebAddressesWrittenByModelIn("The report is on https://EXAMPLE.org/report#summary, and the minutes are on http://10.20.30.40:8000/minutes/. Write to ftp://example.org/upload.");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(restrictions.WebAddressesWrittenByModels, Is.EquivalentTo(new[] { RequestKey("https://example.org/report"), RequestKey("http://10.20.30.40:8000/minutes/") }), "Only addresses a web page could be read from count, compared as the server gets asked for them.");
|
||||
Assert.That(ChatRestrictions.NONE.WebAddressesWrittenByModels, Is.Empty, "Adding addresses leaves NONE as it is.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RestrictionsWhichDemandNothingChangeNothing()
|
||||
{
|
||||
var restrictions = new ChatRestrictions(ConfidenceLevel.MEDIUM, DataSourceSecurity.ALLOW_ANY, new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.DATA_SOURCE, DATA_SOURCE_ID), []).WithWebAddressesWrittenByModelIn("See https://example.org/report.");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
AssertSame(restrictions.StricterOf(ChatRestrictions.NONE), restrictions);
|
||||
AssertSame(ChatRestrictions.NONE.StricterOf(restrictions), restrictions);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheRestrictionsOfAChatComeAlongIntoAnother()
|
||||
{
|
||||
var sourceChat = new ChatThread();
|
||||
sourceChat.RequireProviderConfidence(ConfidenceLevel.HIGH);
|
||||
sourceChat.RequireDataSecurity(DataSourceSecurity.SELF_HOSTED);
|
||||
sourceChat.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, MAILBOX_ID));
|
||||
sourceChat.RequireRestrictions(ChatRestrictions.NONE.WithWebAddressesWrittenByModelIn("See https://example.org/report."));
|
||||
var otherChat = new ChatThread();
|
||||
|
||||
otherChat.RequireRestrictions(sourceChat.CaptureRestrictions());
|
||||
|
||||
AssertSame(otherChat.CaptureRestrictions(), sourceChat.CaptureRestrictions());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ContentWhichDemandsLessLoosensNothing()
|
||||
{
|
||||
var chat = new ChatThread();
|
||||
chat.RequireProviderConfidence(ConfidenceLevel.HIGH);
|
||||
chat.RequireDataSecurity(DataSourceSecurity.SELF_HOSTED);
|
||||
chat.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, MAILBOX_ID));
|
||||
chat.RequireRestrictions(ChatRestrictions.NONE.WithWebAddressesWrittenByModelIn("See https://example.org/report."));
|
||||
var before = chat.CaptureRestrictions();
|
||||
|
||||
chat.RequireRestrictions(new(ConfidenceLevel.LOW, DataSourceSecurity.ALLOW_ANY, new(OutboundDataRestriction.UNRESTRICTED, OutboundDataOrigin.DATA_SOURCE, DATA_SOURCE_ID), []));
|
||||
chat.RequireRestrictions(ChatRestrictions.NONE);
|
||||
|
||||
AssertSame(chat.CaptureRestrictions(), before);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TighteningACopyOfAChatLeavesTheChatAsItIs()
|
||||
{
|
||||
// An assistant starts a chat from a copy of its own thread:
|
||||
var assistantThread = new ChatThread();
|
||||
var chatToStart = assistantThread with { };
|
||||
|
||||
chatToStart.RequireRestrictions(new(ConfidenceLevel.HIGH, DataSourceSecurity.SELF_HOSTED, new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, MAILBOX_ID), []));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
AssertSame(chatToStart.CaptureRestrictions(), new(ConfidenceLevel.HIGH, DataSourceSecurity.SELF_HOSTED, new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, MAILBOX_ID), []));
|
||||
AssertSame(assistantThread.CaptureRestrictions(), ChatRestrictions.NONE);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASnapshotKeepsWhatTheChatDemandedWhenItWasTaken()
|
||||
{
|
||||
var chat = new ChatThread();
|
||||
chat.RequireProviderConfidence(ConfidenceLevel.LOW);
|
||||
var snapshot = chat.CaptureRestrictions();
|
||||
|
||||
chat.RequireProviderConfidence(ConfidenceLevel.HIGH);
|
||||
chat.RequireDataSecurity(DataSourceSecurity.SELF_HOSTED);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(snapshot.ProviderConfidence, Is.EqualTo(ConfidenceLevel.LOW), "Content which left the chat does not hold what the chat read afterward.");
|
||||
Assert.That(snapshot.DataSecurity, Is.EqualTo(DataSourceSecurity.NOT_SPECIFIED));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyASelfHostedProviderMaySeeContentForSelfHostedProvidersOnly()
|
||||
{
|
||||
var cloudProvider = this.AddProvider(CLOUD_PROVIDER_ID, LLMProviders.OPEN_AI, isSelfHosted: false);
|
||||
var selfHostedProvider = this.AddProvider(SELF_HOSTED_PROVIDER_ID, LLMProviders.SELF_HOSTED, isSelfHosted: true);
|
||||
var restrictions = ChatRestrictions.NONE with { DataSecurity = DataSourceSecurity.SELF_HOSTED };
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(restrictions.IsLLMProviderAllowed(cloudProvider), Is.False);
|
||||
Assert.That(restrictions.IsLLMProviderAllowed(selfHostedProvider), Is.True);
|
||||
Assert.That(ChatRestrictions.NONE.IsLLMProviderAllowed(cloudProvider), Is.True, "Content which demands nothing may go to any provider.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AProviderBelowTheDemandedConfidenceMayNotSeeTheContent()
|
||||
{
|
||||
var provider = this.AddProvider(CLOUD_PROVIDER_ID, LLMProviders.OPEN_AI, isSelfHosted: false);
|
||||
var level = provider.GetConfidenceLevel(this.settingsManager);
|
||||
var higherLevels = Enum.GetValues<ConfidenceLevel>().Where(candidate => candidate > level).ToList();
|
||||
Assume.That(higherLevels, Is.Not.Empty, "The provider has the highest level there is, so no content can demand more of it.");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That((ChatRestrictions.NONE with { ProviderConfidence = level }).IsLLMProviderAllowed(provider), Is.True);
|
||||
Assert.That((ChatRestrictions.NONE with { ProviderConfidence = higherLevels.Min() }).IsLLMProviderAllowed(provider), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AChatAndItsRestrictionsAllowTheSameProviders()
|
||||
{
|
||||
var cloudProvider = this.AddProvider(CLOUD_PROVIDER_ID, LLMProviders.OPEN_AI, isSelfHosted: false);
|
||||
var selfHostedProvider = this.AddProvider(SELF_HOSTED_PROVIDER_ID, LLMProviders.SELF_HOSTED, isSelfHosted: true);
|
||||
var chat = new ChatThread();
|
||||
chat.RequireDataSecurity(DataSourceSecurity.SELF_HOSTED);
|
||||
ChatThread? newChat = null;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(chat.IsLLMProviderAllowed(cloudProvider), Is.EqualTo(chat.CaptureRestrictions().IsLLMProviderAllowed(cloudProvider)));
|
||||
Assert.That(chat.IsLLMProviderAllowed(selfHostedProvider), Is.EqualTo(chat.CaptureRestrictions().IsLLMProviderAllowed(selfHostedProvider)));
|
||||
Assert.That(newChat.IsLLMProviderAllowed(cloudProvider), Is.True, "A new chat demands nothing yet.");
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertSame(ChatRestrictions actual, ChatRestrictions expected) => Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(actual.ProviderConfidence, Is.EqualTo(expected.ProviderConfidence));
|
||||
Assert.That(actual.DataSecurity, Is.EqualTo(expected.DataSecurity));
|
||||
Assert.That(actual.OutboundData, Is.EqualTo(expected.OutboundData));
|
||||
Assert.That(actual.WebAddressesWrittenByModels, Is.EquivalentTo(expected.WebAddressesWrittenByModels));
|
||||
});
|
||||
|
||||
private static string RequestKey(string address) => WebAddresses.CreateRequestKey(new Uri(address));
|
||||
|
||||
private AIStudio.Settings.Provider AddProvider(string id, LLMProviders llmProvider, bool isSelfHosted)
|
||||
{
|
||||
var providers = this.settingsManager.ConfigurationData.Providers;
|
||||
providers.Add(new((uint)providers.Count + 1, id, $"Instance {providers.Count + 1}", llmProvider, new("test-model", null), isSelfHosted));
|
||||
return this.settingsManager.GetProviderById(id);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,19 @@ public sealed class ChatThreadRestrictionsOfTests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheAddressesItsModelsWroteComeAlong()
|
||||
{
|
||||
// The moderation of the SWOT analysis carries what the models of the four categories wrote:
|
||||
var researchThread = new ChatThread();
|
||||
researchThread.RequireRestrictions(ChatRestrictions.NONE.WithWebAddressesWrittenByModelIn("A competitor is on https://example.org/competitor."));
|
||||
var assistantThread = new ChatThread();
|
||||
|
||||
assistantThread.RequireRestrictionsOf(researchThread);
|
||||
|
||||
Assert.That(assistantThread.WebAddressesWrittenByModels, Is.EquivalentTo(researchThread.WebAddressesWrittenByModels));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALooserThreadLoosensNothing()
|
||||
{
|
||||
@@ -67,6 +80,7 @@ public sealed class ChatThreadRestrictionsOfTests
|
||||
Assert.That(assistantThread.RequiredProviderConfidence, Is.EqualTo(ConfidenceLevel.NONE));
|
||||
Assert.That(assistantThread.DataSecurity, Is.EqualTo(DataSourceSecurity.NOT_SPECIFIED));
|
||||
Assert.That(assistantThread.RequiredOutboundDataRestriction, Is.EqualTo(OutboundDataRequirement.NONE));
|
||||
Assert.That(assistantThread.WebAddressesWrittenByModels, Is.Empty);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools;
|
||||
using AIStudio.Tools.Services;
|
||||
using AIStudio.Tools.Web;
|
||||
|
||||
@@ -121,6 +124,66 @@ public sealed class ChatThreadWebAddressTests
|
||||
Assert.That(thread.IsWebAddressGivenToTheModel(new Uri("https://example.org/chosen")), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnAddressAModelWroteIsNotGivenInAMessageOfTheUser()
|
||||
{
|
||||
// An answer sent here from another chat, which the user then sent as a message of their own:
|
||||
var thread = Thread(Block(ChatRole.USER, "Make slides of this: the details are on https://example.org/chosen, and our handbook is on https://example.org/handbook.", 1));
|
||||
thread.RequireRestrictions(ChatRestrictions.NONE.WithWebAddressesWrittenByModelIn("The details are on https://example.org/chosen."));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(thread.IsWebAddressGivenToTheModel(new Uri("https://example.org/chosen")), Is.False, "A model wrote it, so it could carry anything of the chat it came from.");
|
||||
Assert.That(thread.IsWebAddressGivenToTheModel(new Uri("https://example.org/handbook")), Is.True, "Another address in the same message still counts.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnAddressAModelWroteIsGivenWhenAToolReturnedItOrADocumentHoldsIt()
|
||||
{
|
||||
var attachedDocument = new ContentText
|
||||
{
|
||||
Text = "Make slides of this: the details are on https://example.org/chosen.",
|
||||
RuntimeAttachmentWebAddresses = new HashSet<string>(StringComparer.Ordinal) { WebAddresses.CreateRequestKey(new Uri("https://example.org/sources/2026")) },
|
||||
};
|
||||
|
||||
var thread = Thread(new ContentBlock { Time = START, ContentType = ContentType.TEXT, Content = attachedDocument, Role = ChatRole.USER });
|
||||
thread.RequireRestrictions(ChatRestrictions.NONE.WithWebAddressesWrittenByModelIn("See https://example.org/chosen and https://example.org/sources/2026."));
|
||||
thread.RuntimeWebAddressesFromTools.Add(WebAddresses.CreateRequestKey(new Uri("https://example.org/chosen")));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(thread.IsWebAddressGivenToTheModel(new Uri("https://example.org/chosen")), Is.True, "A tool returned it in this chat, so it was not only a model which wrote it.");
|
||||
Assert.That(thread.IsWebAddressGivenToTheModel(new Uri("https://example.org/sources/2026")), Is.True, "A document the user attached holds it.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheAddressesAModelWroteOutlastARestart()
|
||||
{
|
||||
var thread = Thread(Block(ChatRole.USER, "Make slides of this: the details are on https://example.org/chosen.", 1));
|
||||
thread.RequireRestrictions(ChatRestrictions.NONE.WithWebAddressesWrittenByModelIn("The details are on https://example.org/chosen."));
|
||||
|
||||
var loaded = JsonSerializer.Deserialize<ChatThread>(JsonSerializer.Serialize(thread, WorkspaceBehaviour.JSON_OPTIONS), WorkspaceBehaviour.JSON_OPTIONS)!;
|
||||
|
||||
Assert.That(loaded.IsWebAddressGivenToTheModel(new Uri("https://example.org/chosen")), Is.False, "Forgetting them would let the chat know more addresses after a restart, not fewer.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ACopyOfAChatKeepsTheAddressesOfTheOtherApart()
|
||||
{
|
||||
var thread = Thread(Block(ChatRole.USER, "Read https://example.org/chosen.", 1));
|
||||
var copy = thread with { };
|
||||
|
||||
copy.RequireRestrictions(ChatRestrictions.NONE.WithWebAddressesWrittenByModelIn("See https://example.org/chosen."));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(copy.IsWebAddressGivenToTheModel(new Uri("https://example.org/chosen")), Is.False);
|
||||
Assert.That(thread.IsWebAddressGivenToTheModel(new Uri("https://example.org/chosen")), Is.True, "What the copy took over must not reach the chat it was copied from.");
|
||||
});
|
||||
}
|
||||
|
||||
private static ChatThread Thread(params ContentBlock[] blocks) => new() { Blocks = [..blocks] };
|
||||
|
||||
private static ContentBlock Block(ChatRole role, string text, int minute, bool hidden = false) => new()
|
||||
|
||||
Reference in new issue
Block a user