mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 19: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
@@ -21,6 +21,10 @@ public sealed class ChatThreadOutboundDataRestrictionTests
|
||||
private const string PRIVATE_MAILBOX_ID = "5c9e2a7b-3f1d-4e8a-b6c4-2d7f9a1e3b5c";
|
||||
private const string WORK_MAILBOX_ID = "8d1f4b6a-2e9c-4a7d-9b3e-6c5a8f2d1e4b";
|
||||
private const string INTRANET_HOST = "intranet.example.org";
|
||||
private const string DATA_SOURCE_ID = "3e8b1d6f-9a2c-4f7e-b5d1-6c4a9e2f8b17";
|
||||
|
||||
// A level of a newer version, or a number written into a file by hand:
|
||||
private const OutboundDataRestriction UNKNOWN_RESTRICTION = (OutboundDataRestriction)5;
|
||||
|
||||
[Test]
|
||||
public void AChatWhichReadNoMailboxDemandsNothing() =>
|
||||
@@ -88,7 +92,49 @@ public sealed class ChatThreadOutboundDataRestrictionTests
|
||||
thread.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID));
|
||||
thread.RequireOutboundDataRestriction(OutboundDataRequirement.NONE);
|
||||
|
||||
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID)), "A search of the local files, say, says nothing about mailboxes and must leave the chat as it was.");
|
||||
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID)), "A search which found nothing, say, brought nothing in and must leave the chat as it was.");
|
||||
}
|
||||
|
||||
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)]
|
||||
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)]
|
||||
[TestCase(OutboundDataRestriction.UNRESTRICTED)]
|
||||
public void ARestrictionThisVersionDoesNotKnowCountsAsTheStrictest(OutboundDataRestriction known)
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(UNKNOWN_RESTRICTION.StricterOf(known), Is.EqualTo(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES));
|
||||
Assert.That(known.StricterOf(UNKNOWN_RESTRICTION), Is.EqualTo(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES));
|
||||
Assert.That(UNKNOWN_RESTRICTION.StricterOf(UNKNOWN_RESTRICTION), Is.EqualTo(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARequirementThisVersionDoesNotKnowTightensTheChat()
|
||||
{
|
||||
var thread = new ChatThread();
|
||||
thread.RequireOutboundDataRestriction(new(UNKNOWN_RESTRICTION, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID));
|
||||
|
||||
Assert.That(thread.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, PRIVATE_MAILBOX_ID)));
|
||||
}
|
||||
|
||||
[TestCase(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)]
|
||||
[TestCase(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)]
|
||||
[TestCase(OutboundDataRestriction.UNRESTRICTED)]
|
||||
public void AChatSavedWithARestrictionThisVersionDoesNotKnowStaysStrict(OutboundDataRestriction arriving)
|
||||
{
|
||||
const string CHAT_WITH_UNKNOWN_RESTRICTION = $$"""
|
||||
{
|
||||
"chat_id": "7a2d5f9c-1e4b-4c8a-b3f6-9d2e5a1c7b84",
|
||||
"name": "A chat of a newer version",
|
||||
"blocks": [],
|
||||
"required_outbound_data_restriction": { "restriction": 5, "origin": "MAILBOX", "reference": "{{WORK_MAILBOX_ID}}" }
|
||||
}
|
||||
""";
|
||||
|
||||
var loaded = JsonSerializer.Deserialize<ChatThread>(CHAT_WITH_UNKNOWN_RESTRICTION, WorkspaceBehaviour.JSON_OPTIONS)!;
|
||||
loaded.RequireOutboundDataRestriction(new(arriving, OutboundDataOrigin.CONFLUENCE_WIKI, string.Empty));
|
||||
|
||||
Assert.That(loaded.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, WORK_MAILBOX_ID)), "Content read later must neither loosen the chat nor take over the name of the source.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -104,6 +150,7 @@ public sealed class ChatThreadOutboundDataRestrictionTests
|
||||
|
||||
[TestCase(OutboundDataOrigin.CONFLUENCE_WIKI, "")]
|
||||
[TestCase(OutboundDataOrigin.INTERNAL_WEB_PAGE, INTRANET_HOST)]
|
||||
[TestCase(OutboundDataOrigin.DATA_SOURCE, DATA_SOURCE_ID)]
|
||||
public void TheOriginSurvivesSavingTheChat(OutboundDataOrigin origin, string reference)
|
||||
{
|
||||
var thread = new ChatThread { ChatId = Guid.NewGuid() };
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.RAG;
|
||||
|
||||
namespace AIStudio.Tests.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Checks where a chat may still send data once passages of a data source reached it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A data source holds the documents of the user or of their organization. Unless somebody chose
|
||||
/// otherwise, a chat which found something in it must not carry that out in a web search. An
|
||||
/// organization may rule out the less strict levels, and a data source which only self-hosted
|
||||
/// providers may use allows the configured services only. Both hold for data sources nobody edits
|
||||
/// again, so they hold when the passages reach a chat, not only in the dialog.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class DataSourceOutboundDataRestrictionTests
|
||||
{
|
||||
private static readonly IDataSource OPEN_FOLDER = new DataSourceLocalDirectory
|
||||
{
|
||||
Id = "4b7e1c9a-2d5f-4a8e-b3c6-9f1d7e2a5c84",
|
||||
Name = "Open folder",
|
||||
OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED,
|
||||
};
|
||||
|
||||
private static readonly IDataSource CLOSED_FILE = new DataSourceLocalFile
|
||||
{
|
||||
Id = "7c2f9e4b-1a6d-4e3c-8b5a-2d9f6c1e4a73",
|
||||
Name = "Closed file",
|
||||
OutboundDataRestriction = OutboundDataRestriction.ONLY_CONFIGURED_SERVICES,
|
||||
};
|
||||
|
||||
private static readonly IDataSource SELF_HOSTED_SERVER = new DataSourceERI_V1
|
||||
{
|
||||
Id = "9e4a2c7f-5b1d-4f6e-a8c3-1d7b4e9f2a65",
|
||||
Name = "Server for self-hosted providers",
|
||||
SecurityPolicy = DataSourceSecurity.SELF_HOSTED,
|
||||
OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED,
|
||||
};
|
||||
|
||||
private static readonly IDataSource OPEN_SERVER = new DataSourceERI_V1
|
||||
{
|
||||
Id = "2a8d5f1c-7e3b-4c9a-b6d2-5f8e1a3c7b49",
|
||||
Name = "Server for any provider",
|
||||
SecurityPolicy = DataSourceSecurity.ALLOW_ANY,
|
||||
OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED,
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void ANewDataSourceAllowsOnlyLinksFromTheChat()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(new DataSourceLocalFile().OutboundDataRestriction, Is.EqualTo(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT));
|
||||
Assert.That(new DataSourceLocalDirectory().OutboundDataRestriction, Is.EqualTo(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT));
|
||||
Assert.That(new DataSourceERI_V1().OutboundDataRestriction, Is.EqualTo(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(nameof(DataSourceType.LOCAL_FILE))]
|
||||
[TestCase(nameof(DataSourceType.LOCAL_DIRECTORY))]
|
||||
[TestCase(nameof(DataSourceType.ERI_V1))]
|
||||
public void ADataSourceStoredBeforeItHadALevelAllowsOnlyLinksFromTheChat(string type)
|
||||
{
|
||||
var dataSource = JsonSerializer.Deserialize<IDataSource>($$"""{"$type_discriminator": "{{type}}", "Name": "Older data source"}""", SettingsManager.JSON_OPTIONS);
|
||||
|
||||
Assert.That(dataSource?.OutboundDataRestriction, Is.EqualTo(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT), "Existing data sources get the level of new ones.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALevelThisVersionCannotReadFallsBackToTheSafeSide()
|
||||
{
|
||||
var dataSource = JsonSerializer.Deserialize<IDataSource>("""{"$type_discriminator": "LOCAL_DIRECTORY", "OutboundDataRestriction": "NOTHING_AT_ALL"}""", SettingsManager.JSON_OPTIONS);
|
||||
|
||||
Assert.That(dataSource?.OutboundDataRestriction, Is.EqualTo(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES), "A newer version may know levels this one does not.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ANumberWhichNamesNoLevelRestrictsTheChatToTheConfiguredServices()
|
||||
{
|
||||
var dataSource = JsonSerializer.Deserialize<IDataSource>("""{"$type_discriminator": "LOCAL_DIRECTORY", "Id": "6f3a9d2e-8c1b-4e7a-b5d4-3a9e7f1c2b68", "OutboundDataRestriction": "5"}""", SettingsManager.JSON_OPTIONS)!;
|
||||
var chat = new ChatThread();
|
||||
|
||||
chat.RequireOutboundDataRestriction(DataSourceRequirements.Of([dataSource], [2], OutboundDataRestriction.UNRESTRICTED).OutboundData);
|
||||
|
||||
Assert.That(chat.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.DATA_SOURCE, dataSource.Id)), "The settings read the number as it is, and compared as it is, it would rank above UNRESTRICTED.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheLevelSurvivesSavingTheSettings()
|
||||
{
|
||||
var loaded = JsonSerializer.Deserialize<IDataSource>(JsonSerializer.Serialize(OPEN_FOLDER, SettingsManager.JSON_OPTIONS), SettingsManager.JSON_OPTIONS);
|
||||
|
||||
Assert.That(loaded?.OutboundDataRestriction, Is.EqualTo(OutboundDataRestriction.UNRESTRICTED));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithoutAnOrganizationTheUserDecides()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(new DataSourceSecuritySettings().MinimumOutboundDataRestriction, Is.EqualTo(OutboundDataRestriction.UNRESTRICTED));
|
||||
Assert.That(OPEN_FOLDER.GetEffectiveOutboundDataRestriction(OutboundDataRestriction.UNRESTRICTED), Is.EqualTo(OutboundDataRestriction.UNRESTRICTED));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheMinimumTightensALooserDataSource() =>
|
||||
Assert.That(OPEN_FOLDER.GetEffectiveOutboundDataRestriction(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT), Is.EqualTo(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT));
|
||||
|
||||
[Test]
|
||||
public void TheMinimumNeverLoosensAStricterDataSource() =>
|
||||
Assert.That(CLOSED_FILE.GetEffectiveOutboundDataRestriction(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT), Is.EqualTo(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES));
|
||||
|
||||
[Test]
|
||||
public void ADataSourceForSelfHostedProvidersAllowsOnlyTheConfiguredServices()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SELF_HOSTED_SERVER.GetEffectiveOutboundDataRestriction(OutboundDataRestriction.UNRESTRICTED), Is.EqualTo(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES), "Stored before, or provided by a configuration plugin, its level never went through the dialog.");
|
||||
Assert.That(OPEN_SERVER.GetEffectiveOutboundDataRestriction(OutboundDataRestriction.UNRESTRICTED), Is.EqualTo(OutboundDataRestriction.UNRESTRICTED));
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(DataSourceSecurity.NOT_SPECIFIED, OutboundDataRestriction.UNRESTRICTED, OutboundDataRestriction.UNRESTRICTED)]
|
||||
[TestCase(DataSourceSecurity.ALLOW_ANY, OutboundDataRestriction.UNRESTRICTED, OutboundDataRestriction.UNRESTRICTED)]
|
||||
[TestCase(DataSourceSecurity.ALLOW_ANY, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT)]
|
||||
[TestCase(DataSourceSecurity.SELF_HOSTED, OutboundDataRestriction.UNRESTRICTED, OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)]
|
||||
[TestCase(DataSourceSecurity.SELF_HOSTED, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataRestriction.ONLY_CONFIGURED_SERVICES)]
|
||||
public void TheLeastStrictLevelFollowsTheOrganizationAndTheSecurityPolicy(DataSourceSecurity securityPolicy, OutboundDataRestriction organizationMinimum, OutboundDataRestriction expected) =>
|
||||
Assert.That(DataSourceOutboundDataExtensions.GetMinimumOutboundDataRestriction(securityPolicy, organizationMinimum), Is.EqualTo(expected));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json;
|
||||
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
|
||||
@@ -48,6 +50,16 @@ public sealed class MailboxOutboundDataRestrictionTests
|
||||
Assert.That(outboundData.Restriction, Is.EqualTo(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ANumberWhichNamesNoLevelRestrictsTheChatToTheConfiguredServices()
|
||||
{
|
||||
var mailbox = JsonSerializer.Deserialize<DataSourceMailbox>("""{"Id": "3c7e1a9f-5d2b-4f8c-a6e3-9b1d4f7a2c56", "OutboundDataRestriction": "5"}""", SettingsManager.JSON_OPTIONS);
|
||||
|
||||
var (_, outboundData) = MailToolResults.GetRequirements([mailbox], OutboundDataRestriction.UNRESTRICTED);
|
||||
|
||||
Assert.That(outboundData, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.MAILBOX, mailbox.Id)), "The settings read the number as it is, and compared as it is, it would rank above UNRESTRICTED.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheDialogOffersOnlyTheLevelsAllowed()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tests.Tools.ToolCalling;
|
||||
using AIStudio.Tools.RAG.RAGProcesses;
|
||||
using AIStudio.Tools.ToolCallingSystem;
|
||||
using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.SemanticSearch;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks where a chat may still send data once the classic RAG process found something in its data sources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The classic process brings passages into the chat without any tool. Afterwards, a web search
|
||||
/// or an address the model writes itself could carry them to a third party, just as after Semantic
|
||||
/// Search. Both ways have to demand the same of the same data sources, or one of them stays open.
|
||||
/// The process runs before the request puts its tools together, so the restriction it sets already
|
||||
/// holds for the answer it prepares.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public sealed class ClassicRagOutboundDataRestrictionTests : ToolRegistryTestBase
|
||||
{
|
||||
private const string WEB_SEARCH = "web_search_test";
|
||||
|
||||
private static readonly IDataSource PROJECT_FILES = new DataSourceLocalDirectory { Num = 1, Id = "11111111-1111-1111-1111-111111111111", Name = "Project files", MaxMatches = 10, ConfidenceLevel = ConfidenceLevel.MEDIUM };
|
||||
private static readonly IDataSource MINUTES = new DataSourceLocalFile { Num = 2, Id = "22222222-2222-2222-2222-222222222222", Name = "Minutes", MaxMatches = 10, ConfidenceLevel = ConfidenceLevel.LOW };
|
||||
private static readonly IDataSource INTRANET = new DataSourceERI_V1 { Num = 3, Id = "33333333-3333-3333-3333-333333333333", Name = "Intranet", MaxMatches = 10, SecurityPolicy = DataSourceSecurity.SELF_HOSTED, OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED };
|
||||
private static readonly IDataSource PRESS_RELEASES = new DataSourceLocalFile { Num = 4, Id = "44444444-4444-4444-4444-444444444444", Name = "Press releases", MaxMatches = 10, OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED };
|
||||
|
||||
private static IEnumerable<TestCaseData> Searches
|
||||
{
|
||||
get
|
||||
{
|
||||
yield return new TestCaseData(new[] { PROJECT_FILES, MINUTES }, new[] { 0, 0 }, OutboundDataRestriction.UNRESTRICTED).SetName("Nothing found");
|
||||
yield return new TestCaseData(new[] { PROJECT_FILES, PRESS_RELEASES }, new[] { 0, 3 }, OutboundDataRestriction.UNRESTRICTED).SetName("Found only content anybody may read");
|
||||
yield return new TestCaseData(new[] { PROJECT_FILES, MINUTES }, new[] { 2, 1 }, OutboundDataRestriction.UNRESTRICTED).SetName("Two equally strict data sources");
|
||||
yield return new TestCaseData(new[] { PRESS_RELEASES, INTRANET, PROJECT_FILES }, new[] { 1, 1, 1 }, OutboundDataRestriction.UNRESTRICTED).SetName("A data source for self-hosted providers only");
|
||||
yield return new TestCaseData(new[] { PRESS_RELEASES, MINUTES }, new[] { 2, 0 }, OutboundDataRestriction.ONLY_CONFIGURED_SERVICES).SetName("A minimum of the organization");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARetrievalWhichFoundNothingLeavesTheChatAsItWas()
|
||||
{
|
||||
var chat = new ChatThread();
|
||||
chat.RequireOutboundDataRestriction(new(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.CONFLUENCE_WIKI, string.Empty));
|
||||
|
||||
AISrcSelWithRetCtxVal.RequireOutboundDataRestriction(chat, [PROJECT_FILES, INTRANET], [0, 0], OutboundDataRestriction.UNRESTRICTED);
|
||||
|
||||
Assert.That(chat.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.CONFLUENCE_WIKI, string.Empty)), "Neither data source brought anything into the chat.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyTheDataSourcesWithPassagesRestrictTheChat()
|
||||
{
|
||||
var chat = new ChatThread();
|
||||
|
||||
AISrcSelWithRetCtxVal.RequireOutboundDataRestriction(chat, [INTRANET, MINUTES], [0, 4], OutboundDataRestriction.UNRESTRICTED);
|
||||
|
||||
Assert.That(chat.RequiredOutboundDataRestriction, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.DATA_SOURCE, MINUTES.Id)), "The intranet found nothing, so it demands nothing either.");
|
||||
}
|
||||
|
||||
[TestCaseSource(nameof(Searches))]
|
||||
public void BothWaysOfSearchingRestrictTheChatAlike(IDataSource[] dataSources, int[] passageCounts, OutboundDataRestriction organizationMinimum)
|
||||
{
|
||||
//
|
||||
// Semantic Search offers the data sources in a fixed order, while the selection agent of the
|
||||
// classic process may return them in any order. Both have to name the same data source:
|
||||
//
|
||||
var classicChat = new ChatThread();
|
||||
AISrcSelWithRetCtxVal.RequireOutboundDataRestriction(classicChat, dataSources.Reverse().ToArray(), passageCounts.Reverse().ToArray(), organizationMinimum);
|
||||
|
||||
var semanticSearchChat = new ChatThread();
|
||||
semanticSearchChat.RequireOutboundDataRestriction(SemanticSearchTool.GetRequirements(dataSources, passageCounts, organizationMinimum).OutboundData);
|
||||
|
||||
Assert.That(classicChat.RequiredOutboundDataRestriction, Is.EqualTo(semanticSearchChat.RequiredOutboundDataRestriction));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheAnswerWhoseRetrievalRestrictedTheChatIsNotOfferedTheWebSearch()
|
||||
{
|
||||
var webSearch = new TestTool(Definition(WEB_SEARCH)) { OutboundData = ToolOutboundData.THIRD_PARTY_QUERIES };
|
||||
var registry = this.CreateRegistry(webSearch);
|
||||
|
||||
//
|
||||
// The chat puts the tools of the answer together before the classic process runs, while
|
||||
// the chat is still unrestricted. Then the process finds something:
|
||||
//
|
||||
var chat = new ChatThread { RuntimeSelectedToolIds = [WEB_SEARCH] };
|
||||
AISrcSelWithRetCtxVal.RequireOutboundDataRestriction(chat, [PROJECT_FILES], [2], OutboundDataRestriction.UNRESTRICTED);
|
||||
|
||||
var provider = ToolCapableProvider();
|
||||
var context = new ToolResolutionContext
|
||||
{
|
||||
Provider = provider,
|
||||
Component = AIStudio.Tools.Components.CHAT,
|
||||
ProviderConfidence = provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level,
|
||||
ChatThread = chat,
|
||||
};
|
||||
var runnableTools = await registry.GetRunnableToolsAsync(context, chat.RuntimeSelectedToolIds, mayRunTools: true);
|
||||
|
||||
Assert.That(runnableTools.Select(tool => tool.Definition.Id), Does.Not.Contain(WEB_SEARCH), "The request reads the restriction when it puts its tools together, after the classic process.");
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,7 @@ public sealed class DataSourceRelocationWorkflowTests
|
||||
{
|
||||
Assert.That(edit.Warning.ShowCount, Is.Zero, "Nothing was prepared for the data source, so nothing can be lost.");
|
||||
Assert.That(saved is DataSourceLocalDirectory folder ? folder.Path : ((DataSourceLocalFile)saved).FilePath, Is.EqualTo(newPath));
|
||||
Assert.That(saved.OutboundDataRestriction, Is.EqualTo(OutboundDataRestriction.UNRESTRICTED), "The dialog creates the data source anew, so a value it does not carry over falls back to its default.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -617,12 +618,13 @@ public sealed class DataSourceRelocationWorkflowTests
|
||||
{
|
||||
Id = id, Name = "Existing documents", Type = DataSourceType.LOCAL_DIRECTORY, Path = root,
|
||||
EmbeddingId = EMBEDDING_ID, ConfidenceLevel = ConfidenceLevel.NONE, MaxChunkTokenLength = 512, ChunkOverlapTokenLength = 100,
|
||||
FilePatterns = "*.md", ExcludePatterns = string.Empty,
|
||||
FilePatterns = "*.md", ExcludePatterns = string.Empty, OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED,
|
||||
};
|
||||
this.source = singleFile ? new DataSourceLocalFile
|
||||
{
|
||||
Id = id, Name = folder.Name, Type = DataSourceType.LOCAL_FILE, FilePath = Path.Combine(root, "notes.md"),
|
||||
EmbeddingId = EMBEDDING_ID, ConfidenceLevel = ConfidenceLevel.NONE, MaxChunkTokenLength = 512, ChunkOverlapTokenLength = 100,
|
||||
OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED,
|
||||
} : folder;
|
||||
this.settings.ConfigurationData.DataSources.Add(this.source);
|
||||
|
||||
@@ -685,6 +687,7 @@ public sealed class DataSourceRelocationWorkflowTests
|
||||
SetField(component, "dataName", storedSource.Name);
|
||||
SetField(component, "dataEmbeddingId", storedSource.EmbeddingId);
|
||||
SetField(component, "dataConfidenceLevel", storedSource.ConfidenceLevel);
|
||||
SetField(component, "dataOutboundDataRestriction", storedSource.OutboundDataRestriction);
|
||||
SetField(component, "dataMaxChunkTokenLength", storedSource.MaxChunkTokenLength);
|
||||
SetField(component, "dataChunkOverlapTokenLength", storedSource.ChunkOverlapTokenLength);
|
||||
SetField(component, storedSource is DataSourceLocalDirectory ? "dataPath" : "dataFilePath", newPath);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
using AIStudio.Tools.RAG;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks what a chat requires from now on, because passages of data sources reached the model.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Semantic Search and the classic RAG process both ask here, so that both demand the same of the
|
||||
/// same data sources. Only the data sources with passages count: one search in the wrong data
|
||||
/// source must not lock a chat for good. Of several equally strict data sources, the chat names
|
||||
/// the same one, whichever way searched them and in whichever order.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class DataSourceRequirementsTests
|
||||
{
|
||||
private static readonly IDataSource HANDBOOK = new DataSourceLocalDirectory { Num = 1, Id = "11111111-1111-1111-1111-111111111111", Name = "Handbook", MaxMatches = 10, ConfidenceLevel = ConfidenceLevel.HIGH };
|
||||
private static readonly IDataSource MINUTES = new DataSourceLocalFile { Num = 2, Id = "22222222-2222-2222-2222-222222222222", Name = "Minutes", MaxMatches = 10, ConfidenceLevel = ConfidenceLevel.LOW, OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED };
|
||||
private static readonly IDataSource INTRANET = new DataSourceERI_V1 { Num = 3, Id = "33333333-3333-3333-3333-333333333333", Name = "Intranet", MaxMatches = 10, SecurityPolicy = DataSourceSecurity.SELF_HOSTED };
|
||||
private static readonly IDataSource ARCHIVE = new DataSourceLocalDirectory { Num = 4, Id = "44444444-4444-4444-4444-444444444444", Name = "Archive", MaxMatches = 10, ConfidenceLevel = ConfidenceLevel.LOW };
|
||||
|
||||
[Test]
|
||||
public void ASearchWhichFoundNothingRequiresNothing() =>
|
||||
Assert.That(DataSourceRequirements.Of([HANDBOOK, INTRANET], [0, 0], OutboundDataRestriction.UNRESTRICTED), Is.EqualTo(DataSourceRequirements.NONE));
|
||||
|
||||
[Test]
|
||||
public void OnlyTheDataSourcesWithPassagesCount()
|
||||
{
|
||||
var requirements = DataSourceRequirements.Of([HANDBOOK, MINUTES, INTRANET], [0, 2, 0], OutboundDataRestriction.UNRESTRICTED);
|
||||
|
||||
Assert.That(requirements, Is.EqualTo(new DataSourceRequirements(ConfidenceLevel.LOW, DataSourceSecurity.ALLOW_ANY, OutboundDataRequirement.NONE)), "Only the minutes brought something in, and they allow every tool.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheStrictestDataSourceDecides()
|
||||
{
|
||||
var requirements = DataSourceRequirements.Of([MINUTES, HANDBOOK, INTRANET], [1, 1, 1], OutboundDataRestriction.UNRESTRICTED);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(requirements.Confidence, Is.EqualTo(ConfidenceLevel.HIGH));
|
||||
Assert.That(requirements.Security, Is.EqualTo(DataSourceSecurity.SELF_HOSTED));
|
||||
Assert.That(requirements.OutboundData, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, OutboundDataOrigin.DATA_SOURCE, INTRANET.Id)), "The intranet may only be seen by self-hosted providers.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnATieTheFirstDataSourceByNumberIsNamed()
|
||||
{
|
||||
var expected = new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.DATA_SOURCE, HANDBOOK.Id);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(DataSourceRequirements.Of([HANDBOOK, ARCHIVE], [1, 1], OutboundDataRestriction.UNRESTRICTED).OutboundData, Is.EqualTo(expected));
|
||||
Assert.That(DataSourceRequirements.Of([ARCHIVE, HANDBOOK], [1, 1], OutboundDataRestriction.UNRESTRICTED).OutboundData, Is.EqualTo(expected), "An agent may select the data sources in another order.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnOrganizationMinimumTightensTheDataSource()
|
||||
{
|
||||
var requirements = DataSourceRequirements.Of([MINUTES], [1], OutboundDataRestriction.ONLY_LINKS_FROM_CHAT);
|
||||
|
||||
Assert.That(requirements.OutboundData, Is.EqualTo(new OutboundDataRequirement(OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, OutboundDataOrigin.DATA_SOURCE, MINUTES.Id)), "The chat names the data source it found something in, even though the level comes from the organization.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CountsWhichDoNotMatchTheDataSourcesAreRefused() =>
|
||||
Assert.That(() => DataSourceRequirements.Of([HANDBOOK, MINUTES], [1], OutboundDataRestriction.UNRESTRICTED), Throws.ArgumentException);
|
||||
}
|
||||
@@ -282,6 +282,24 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase(null, OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, TestName = "A data source without a level gets the one of a new data source")]
|
||||
[TestCase("\"ONLY_CONFIGURED_SERVICES\"", OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, TestName = "A data source keeps the level it names")]
|
||||
[TestCase("\"only_links_from_chat\"", OutboundDataRestriction.ONLY_LINKS_FROM_CHAT, TestName = "A level is read regardless of case")]
|
||||
[TestCase("\"NOTHING_AT_ALL\"", OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, TestName = "A level this version does not know counts as the strictest")]
|
||||
[TestCase("\"2\"", OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, TestName = "A number is no level, even as text")]
|
||||
[TestCase("2", OutboundDataRestriction.ONLY_CONFIGURED_SERVICES, TestName = "A number is no level")]
|
||||
public void ADataSourceReadsWhereAChatMaySendData(string? luaValue, OutboundDataRestriction expected)
|
||||
{
|
||||
var (_, exported) = ExportDataSource(AuthMethod.NONE);
|
||||
const string EXPORTED_LINE = "[\"OutboundDataRestriction\"] = \"UNRESTRICTED\",";
|
||||
Assert.That(exported, Does.Contain(EXPORTED_LINE));
|
||||
|
||||
var snippet = exported.Replace(EXPORTED_LINE, luaValue is null ? string.Empty : $"[\"OutboundDataRestriction\"] = {luaValue},", StringComparison.Ordinal);
|
||||
var dataSource = (DataSourceERI_V1)Import(snippet, PluginConfigurationObjectType.DATA_SOURCE).ConfigurationObject;
|
||||
|
||||
Assert.That(dataSource.OutboundDataRestriction, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AttachmentsWhichAreNotOnThisComputerAreToBeRelinked()
|
||||
{
|
||||
@@ -724,6 +742,10 @@ public sealed class ConfigurationSnippetImportTests : ToolRegistryTestBase
|
||||
AuthMethod = authMethod,
|
||||
Username = username,
|
||||
SecurityPolicy = DataSourceSecurity.SELF_HOSTED,
|
||||
|
||||
// Not the default, so that a parser which ignores the level does not pass unnoticed. The
|
||||
// stored level travels, not the stricter one the security policy demands at runtime:
|
||||
OutboundDataRestriction = OutboundDataRestriction.UNRESTRICTED,
|
||||
SelectedRetrievalId = "semantic-search",
|
||||
MaxMatches = 25,
|
||||
};
|
||||
|
||||
@@ -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