mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 20:47:22 +00:00
Outbound data restrictions after searching data sources (#1106)
This commit is contained in:
1 parent
7ba5f01388
commit
8c5cfa78b6
61 files changed
+1389
-143
No files matched your search
@@ -0,0 +1,127 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations;
|
||||
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.SemanticSearch;
|
||||
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch;
|
||||
using AIStudio.Tools.Web;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Tools.ToolCalling;
|
||||
|
||||
/// <summary>
|
||||
/// Checks where a chat may still send data once Semantic Search found something in its data sources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A data source holds the documents of the user or of their organization. A web search or an
|
||||
/// address the model writes itself would carry them to a third party, so a search which found
|
||||
/// something restricts the chat as a mailbox or the wiki does. By default, the chat keeps the links
|
||||
/// from the chat. A data source which only self-hosted providers may use allows the configured
|
||||
/// services only: the internal pages it links to stay readable, a public page does not. The search
|
||||
/// itself needs indexed data sources and is left to a manual test; these tests start from what the
|
||||
/// passages of a search demand.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public sealed class SemanticSearchOutboundDataRestrictionTests : ToolRegistryTestBase
|
||||
{
|
||||
private const string INTRANET_PAGE = "https://intranet.example.org/projects/budget";
|
||||
private const string PUBLIC_PAGE = "https://example.org/press/2026-10";
|
||||
private const string CHOSEN_BY_THE_MODEL = "https://attacker.example/?budget=2027";
|
||||
|
||||
private static readonly IDataSource PROJECT_FILES = new DataSourceLocalDirectory { Num = 1, Id = "5e2b8d4f-1c7a-4e9b-a3d6-8f4c2e7b1a59", Name = "Project files", MaxMatches = 10 };
|
||||
private static readonly IDataSource INTRANET = new DataSourceERI_V1 { Num = 2, Id = "8a3f6c1e-4d9b-4b2a-9e7c-3d6a1f8b5c27", Name = "Intranet", MaxMatches = 10, SecurityPolicy = DataSourceSecurity.SELF_HOSTED, OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED };
|
||||
private static readonly IDataSource PRESS_RELEASES = new DataSourceLocalFile { Num = 3, Id = "2d7c4a9e-6b1f-4c8d-b5a3-7e2f9c4d1b86", Name = "Press releases", MaxMatches = 10, OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED };
|
||||
|
||||
private static readonly Func<string, bool> ALLOWED_PRIVATE_HOSTS = host => string.Equals(host, "intranet.example.org", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
[Test]
|
||||
public void ASearchRestrictsTheChatToLinksFromTheChatByDefault()
|
||||
{
|
||||
var requirements = SemanticSearchTool.GetRequirements([PROJECT_FILES, PRESS_RELEASES], [3, 0], OutboundDataRestriction.UNRESTRICTED);
|
||||
Assert.That(requirements.OutboundData, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.DATA_SOURCE, PROJECT_FILES.Id)), "The chat names the data source, so the user learns why web search is gone.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASearchInADataSourceWithoutRestrictionRestrictsNothing()
|
||||
{
|
||||
var requirements = SemanticSearchTool.GetRequirements([PROJECT_FILES, PRESS_RELEASES], [0, 2], OutboundDataRestriction.UNRESTRICTED);
|
||||
Assert.That(requirements.OutboundData, Is.EqualTo(OutboundDataRequirement.NONE), "The project files found nothing, and the press releases may be read by anybody.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnOrganizationMinimumTightensWhatASearchDemands()
|
||||
{
|
||||
var requirements = SemanticSearchTool.GetRequirements([PRESS_RELEASES], [2], OutboundDataRestriction.ONLY_CONFIGURED_SERVICES);
|
||||
Assert.That(requirements.OutboundData.Restriction, Is.EqualTo(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AfterAHitWebSearchIsNotOffered()
|
||||
{
|
||||
IToolImplementation webSearch = new WebSearchTool([], null!, null!, NullLogger<WebSearchTool>.Instance);
|
||||
Assert.That(ToolSelectionRules.IsOutboundDataAllowed(ChatAfterASearch(PROJECT_FILES).RequiredOutboundDataRestriction.Restriction, webSearch), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AfterAHitAWebSearchIsTurnedDownBeforeItRuns()
|
||||
{
|
||||
this.SettingsManager.ConfigurationData.DataSources.Add(PROJECT_FILES);
|
||||
|
||||
//
|
||||
// The request offered the web search while the chat was still unrestricted. Then Semantic
|
||||
// Search brought in the passages, and the model calls the web search in the same request:
|
||||
//
|
||||
var hasRun = false;
|
||||
var webSearch = new TestTool(Definition(), execute: _ =>
|
||||
{
|
||||
hasRun = true;
|
||||
return new ToolExecutionResult { TextContent = "Results about the budget." };
|
||||
}) { OutboundData = ToolOutboundData.THIRD_PARTY_QUERIES };
|
||||
|
||||
var executor = new ToolExecutor(this.CreateToolSettingsService(), NullLogger<ToolExecutor>.Instance);
|
||||
var outcome = await executor.ExecuteAsync("call-1", TOOL_ID, "{}", [(webSearch.GetDefinition(), webSearch)], new NoProvider(), ChatAfterASearch(PROJECT_FILES), order: 1);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(hasRun, Is.False, "The content of the project files would already have left AI Studio.");
|
||||
Assert.That(outcome.Trace.Status, Is.EqualTo(ToolInvocationTraceStatus.BLOCKED));
|
||||
Assert.That(outcome.Content, Does.Contain("Project files"), "The user learns which data source stands in the way.");
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(INTRANET_PAGE, true)]
|
||||
[TestCase(PUBLIC_PAGE, true)]
|
||||
[TestCase(CHOSEN_BY_THE_MODEL, false)]
|
||||
public void AfterAHitTheLinksOfTheHitsAreRead(string address, bool expectedAllowed)
|
||||
{
|
||||
var chat = ChatAfterASearch(PROJECT_FILES);
|
||||
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(address), chat.RequiredOutboundDataRestriction.Restriction, chat, wiki: null, ALLOWED_PRIVATE_HOSTS, out _);
|
||||
Assert.That(allowed, Is.EqualTo(expectedAllowed), "The model may follow the links of what it found, but it cannot write an address of its own.");
|
||||
}
|
||||
|
||||
[TestCase(INTRANET_PAGE, true)]
|
||||
[TestCase(PUBLIC_PAGE, false)]
|
||||
[TestCase(CHOSEN_BY_THE_MODEL, false)]
|
||||
public void AfterAHitInADataSourceForSelfHostedProvidersOnlyInternalPagesAreRead(string address, bool expectedAllowed)
|
||||
{
|
||||
var chat = ChatAfterASearch(INTRANET);
|
||||
var allowed = ReadWebPageTool.IsAllowedByOutboundDataRestriction(new Uri(address), chat.RequiredOutboundDataRestriction.Restriction, chat, wiki: null, ALLOWED_PRIVATE_HOSTS, out _);
|
||||
Assert.That(allowed, Is.EqualTo(expectedAllowed), "An internal page the data source links to stays readable, a public one does not, although the hit names it.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A chat in which Semantic Search found passages in the data source, linking to an internal and to a public page.
|
||||
/// </summary>
|
||||
private static ChatThread ChatAfterASearch(IDataSource dataSource)
|
||||
{
|
||||
var thread = new ChatThread();
|
||||
thread.RuntimeWebAddressesFromTools.Add(WebAddresses.CreateRequestKey(new Uri(INTRANET_PAGE)));
|
||||
thread.RuntimeWebAddressesFromTools.Add(WebAddresses.CreateRequestKey(new Uri(PUBLIC_PAGE)));
|
||||
thread.RequireOutboundDataRestriction(SemanticSearchTool.GetRequirements([dataSource], [2], OutboundDataRestriction.UNRESTRICTED).OutboundData);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ public sealed class SemanticSearchToolDescriptionTests
|
||||
{
|
||||
var descriptions = new Dictionary<string, string> { [HANDBOOK.Id] = "Our processes.", [MINUTES.Id] = "Meetings.", [INTRANET.Id] = "Everything else." };
|
||||
ToolFunctionDefinition DescribeInOfferOrder(params IDataSource[] dataSources) =>
|
||||
Describe(SemanticSearchTool.InOfferOrder(dataSources).Select(dataSource => (dataSource, descriptions[dataSource.Id])).ToArray());
|
||||
Describe(dataSources.InFixedOrder().Select(dataSource => (dataSource, descriptions[dataSource.Id])).ToArray());
|
||||
|
||||
var first = DescribeInOfferOrder(INTRANET, MINUTES, HANDBOOK);
|
||||
var second = DescribeInOfferOrder(MINUTES, HANDBOOK, INTRANET);
|
||||
|
||||
@@ -28,25 +28,29 @@ public sealed class SemanticSearchToolResultTests
|
||||
[Test]
|
||||
public void ASearchWhichFoundNothingRequiresNothing()
|
||||
{
|
||||
var requirements = SemanticSearchTool.GetRequirements([HANDBOOK, INTRANET], [0, 0]);
|
||||
var requirements = SemanticSearchTool.GetRequirements([HANDBOOK, INTRANET], [0, 0], OutboundDataRestriction.UNRESTRICTED);
|
||||
|
||||
Assert.That(requirements, Is.EqualTo((ConfidenceLevel.NONE, DataSourceSecurity.NOT_SPECIFIED)), "NOT_SPECIFIED leaves the data security of the chat as it was.");
|
||||
Assert.That(requirements, Is.EqualTo(DataSourceRequirements.NONE), "NOT_SPECIFIED leaves the data security of the chat as it was.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyTheDataSourcesWithPassagesRaiseTheRequirements()
|
||||
{
|
||||
var requirements = SemanticSearchTool.GetRequirements([HANDBOOK, MINUTES, INTRANET], [0, 2, 0]);
|
||||
var requirements = SemanticSearchTool.GetRequirements([HANDBOOK, MINUTES, INTRANET], [0, 2, 0], OutboundDataRestriction.UNRESTRICTED);
|
||||
|
||||
Assert.That(requirements, Is.EqualTo((ConfidenceLevel.LOW, DataSourceSecurity.ALLOW_ANY)), "Neither the highly confidential handbook nor the self-hosted intranet brought anything into the chat.");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That((requirements.Confidence, requirements.Security), Is.EqualTo((ConfidenceLevel.LOW, DataSourceSecurity.ALLOW_ANY)), "Neither the highly confidential handbook nor the self-hosted intranet brought anything into the chat.");
|
||||
Assert.That(requirements.OutboundData, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.DATA_SOURCE, MINUTES.Id)), "Only the minutes restrict where the chat may still send data.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASelfHostedDataSourceWithPassagesRestrictsTheChat()
|
||||
{
|
||||
var requirements = SemanticSearchTool.GetRequirements([MINUTES, INTRANET], [1, 1]);
|
||||
var requirements = SemanticSearchTool.GetRequirements([MINUTES, INTRANET], [1, 1], OutboundDataRestriction.UNRESTRICTED);
|
||||
|
||||
Assert.That(requirements, Is.EqualTo((ConfidenceLevel.LOW, DataSourceSecurity.SELF_HOSTED)));
|
||||
Assert.That((requirements.Confidence, requirements.Security), Is.EqualTo((ConfidenceLevel.LOW, DataSourceSecurity.SELF_HOSTED)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -4,6 +4,7 @@ using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations;
|
||||
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
|
||||
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.SemanticSearch;
|
||||
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -32,7 +33,7 @@ public sealed class ToolOrderInstructionsTests : ToolRegistryTestBase
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
foreach (var toolId in new[] { ToolSelectionRules.SEARCH_MAILS_TOOL_ID, ToolSelectionRules.READ_MAIL_TOOL_ID, ToolSelectionRules.COUNT_MAILS_TOOL_ID, ToolSelectionRules.SEARCH_CONFLUENCE_TOOL_ID })
|
||||
foreach (var toolId in new[] { ToolSelectionRules.SEARCH_MAILS_TOOL_ID, ToolSelectionRules.READ_MAIL_TOOL_ID, ToolSelectionRules.COUNT_MAILS_TOOL_ID, ToolSelectionRules.SEARCH_CONFLUENCE_TOOL_ID, ToolSelectionRules.SEMANTIC_SEARCH_TOOL_ID })
|
||||
Assert.That(instructions, Does.Not.Contain($"`{toolId}`"), "The web search would name tools the request may not offer, and each of these words the rule itself.");
|
||||
});
|
||||
}
|
||||
@@ -82,6 +83,46 @@ public sealed class ToolOrderInstructionsTests : ToolRegistryTestBase
|
||||
Assert.That(instructions, Does.Not.Contain("web_search"), "The request does not offer the web search, so a rule about it would only confuse the model.");
|
||||
}
|
||||
|
||||
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, true, true)]
|
||||
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, true, true)]
|
||||
[TestCase(OutboundDataRestriction.UNRESTRICTED, true, false)]
|
||||
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, false, false)]
|
||||
public async Task TheSemanticSearchSaysSoWhenItEndsTheWebSearch(OutboundDataRestriction restriction, bool offersWebSearch, bool expected)
|
||||
{
|
||||
var dataSource = new DataSourceLocalDirectory { Id = "6f1a8c3e-9d2b-4e7a-b4c5-1e8d3f6a9c72", Name = "Project files", OutboundDataRestriction = restriction };
|
||||
this.SettingsManager.ConfigurationData.DataSources.Add(dataSource);
|
||||
var chat = new ChatThread { DataSourceOptions = new() { PreselectedDataSourceIds = [dataSource.Id] } };
|
||||
var tool = new SemanticSearchTool(this.SettingsManager, null!, null!, null!, NullLogger<SemanticSearchTool>.Instance);
|
||||
string[] candidates = offersWebSearch ? [ToolSelectionRules.SEMANTIC_SEARCH_TOOL_ID, ToolSelectionRules.WEB_SEARCH_TOOL_ID] : [ToolSelectionRules.SEMANTIC_SEARCH_TOOL_ID];
|
||||
|
||||
var instructions = await tool.ResolveSystemPromptInstructionsAsync(tool.GetDefinition(), this.ResolutionContext(chat, candidates));
|
||||
|
||||
Assert.That(instructions.Contains("search the web before you search the data sources", StringComparison.Ordinal), Is.EqualTo(expected), "Without a restriction, a search leaves the web search in place, and the rule would only cost the model a detour.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyTheDataSourcesTheModelMaySearchCount()
|
||||
{
|
||||
var open = new DataSourceLocalFile { Id = "3c9e1a7f-2b5d-4f8c-a6e1-9d4b7c2a5f13", Name = "Press releases", OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED };
|
||||
var closed = new DataSourceLocalDirectory { Id = "7b4d2f9a-5e1c-4a6b-8d3f-2c9a6e1b4d58", Name = "Project files" };
|
||||
var settings = new Data { DataSources = [open, closed] };
|
||||
var selected = new ChatThread { DataSourceOptions = new() { PreselectedDataSourceIds = [open.Id] } };
|
||||
var automatic = new ChatThread { DataSourceOptions = new() { AutomaticDataSourceSelection = true } };
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SemanticSearchTool.EndsWebSearch(this.ResolutionContext(selected), settings), Is.False, "The project files are not selected, so the model cannot find anything in them.");
|
||||
Assert.That(SemanticSearchTool.EndsWebSearch(this.ResolutionContext(automatic), settings), Is.True, "With the automatic selection, the model may search the project files as well.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheSemanticSearchCountsWithTheRule()
|
||||
{
|
||||
var instructions = new SemanticSearchTool(null!, null!, null!, null!, null!).GetDefinition().SystemPromptInstructions;
|
||||
Assert.That(instructions, Does.Contain("search the web before you search the data sources"), "The token count assumes a request which offers the web search and a data source which ends it.");
|
||||
}
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
public void ReadWebPageSaysSoWhenAnInternalPageEndsTheWebSearch(bool internalPageEndsWebSearch)
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace AIStudio.Tests.Tools.ToolCalling;
|
||||
public sealed class ToolOutboundDataTests : ToolRegistryTestBase
|
||||
{
|
||||
private const string MAILBOX_ID = "6e3a9d2f-1b7c-4f8e-a5d4-9c2b7e1f3a6d";
|
||||
private const string DATA_SOURCE_ID = "1c7e4a9f-3b6d-4e2a-8f5c-9d2b6e1a4c38";
|
||||
|
||||
[TestCase(OutboundDataRestriction.UNRESTRICTED, ToolOutboundData.NONE, true)]
|
||||
[TestCase(OutboundDataRestriction.UNRESTRICTED, ToolOutboundData.CONFIGURED_SERVICE, true)]
|
||||
@@ -185,6 +186,34 @@ public sealed class ToolOutboundDataTests : ToolRegistryTestBase
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADataSourceIsNamedByItsName()
|
||||
{
|
||||
var requirement = new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.DATA_SOURCE, DATA_SOURCE_ID);
|
||||
var message = requirement.GetToolBlockedMessage(new Data { DataSources = [new DataSourceLocalDirectory { Id = DATA_SOURCE_ID, Name = "Project files" }] });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(message, Does.Contain("Project files"), "The user learns which data source stands in the way.");
|
||||
Assert.That(message, Does.Not.Contain("mailbox"), "No mailbox restricted the chat.");
|
||||
Assert.That(message, Does.Contain(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT.GetName()));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADataSourceRemovedSinceIsNotNamed()
|
||||
{
|
||||
var requirement = new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.DATA_SOURCE, DATA_SOURCE_ID);
|
||||
var message = requirement.GetToolBlockedMessage(new Data { DataSources = [new DataSourceLocalFile { Id = "another-data-source", Name = "Minutes" }] });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(message, Does.Contain("removed since"));
|
||||
Assert.That(message, Does.Not.Contain("Minutes"), "Another data source did not restrict the chat.");
|
||||
Assert.That(message, Does.Contain(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES.GetName()), "The restriction stays, so the user still learns why.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ACallTurnedDownByTheWikiNamesTheWiki()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user