mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-17 06:38:00 +00:00
Support multiple web search backends: SearXNG, Staan, and Tavily (#948)
This commit is contained in:
1 parent
4d8d30e15e
commit
4141e7c504
57 files changed
+3734
-1088
No files matched your search
+12
@@ -0,0 +1,12 @@
|
||||
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan;
|
||||
|
||||
/// <summary>
|
||||
/// What Staan says about the query it actually ran.
|
||||
/// </summary>
|
||||
internal sealed record StaanQueryInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// The query Staan searched for after correcting it, or empty when it searched what it was given.
|
||||
/// </summary>
|
||||
public string AlteredQuery { get; init; } = string.Empty;
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
using AIStudio.Tools.PluginSystem;
|
||||
|
||||
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan;
|
||||
|
||||
/// <summary>
|
||||
/// Searches through Staan, a European search index reachable with an API key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the backend for someone who wants a working web search without running a search
|
||||
/// instance: an API key is copied into the settings and that is all. In exchange Staan is
|
||||
/// narrower than a self-hosted instance. It searches one of three markets at a time, filters
|
||||
/// neither by time nor for explicit results, and serves ten hits per page up to the fourth
|
||||
/// page.<br/><br/>
|
||||
/// Only Staan's base search API is used. Its variant for AI agents returns whole pages and
|
||||
/// costs twice as much, while this tool loads, cleans, and checks the pages itself anyway.
|
||||
/// </remarks>
|
||||
public sealed class StaanSearchBackend : IWebSearchBackend
|
||||
{
|
||||
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(StaanSearchBackend).Namespace, nameof(StaanSearchBackend));
|
||||
|
||||
private const string SETTINGS_GROUP = "staan";
|
||||
|
||||
private const string API_KEY_SETTING = $"{SETTINGS_GROUP}.apiKey";
|
||||
|
||||
private const string MARKET_SETTING = $"{SETTINGS_GROUP}.market";
|
||||
|
||||
private const string MARKET_GERMANY = "de-de";
|
||||
|
||||
private const string MARKET_UNITED_STATES = "en-us";
|
||||
|
||||
private const string MARKET_FRANCE = "fr-fr";
|
||||
|
||||
/// <remarks>
|
||||
/// Staan itself falls back to the French market. This app is English by default and its
|
||||
/// users are anywhere, so the widest index is the better answer to an unset market.
|
||||
/// </remarks>
|
||||
private const string DEFAULT_MARKET = MARKET_UNITED_STATES;
|
||||
|
||||
private static readonly string[] SUPPORTED_MARKETS = [MARKET_GERMANY, MARKET_UNITED_STATES, MARKET_FRANCE];
|
||||
|
||||
/// <summary>
|
||||
/// Staan serves ten hits per page and accepts an offset of at most 30, which is four pages.
|
||||
/// </summary>
|
||||
private const int RESULTS_PER_PAGE = 10;
|
||||
|
||||
private const int MAX_PAGE = 4;
|
||||
|
||||
private const int MAX_QUERY_CHARACTERS = 400;
|
||||
|
||||
private readonly StaanSearchClient searchClient = new();
|
||||
|
||||
public WebSearchBackend Backend => WebSearchBackend.STAAN;
|
||||
|
||||
public string SettingsGroup => SETTINGS_GROUP;
|
||||
|
||||
/// <remarks>
|
||||
/// Staan's search takes a query, a market, and an offset, and nothing else: there is no
|
||||
/// safe search parameter and no way to ask for recent results. The market is what restricts
|
||||
/// the language, which is the one filter Staan does have — even though it restricts the
|
||||
/// region along with it.
|
||||
/// </remarks>
|
||||
public WebSearchCapabilities Capabilities { get; } = new(SupportsSafeSearch: false, SupportsTimeRange: false, SupportsLanguage: true, MaxPage: MAX_PAGE);
|
||||
|
||||
public void DeclareSettings(ToolSettingsSchemaBuilder builder) => builder
|
||||
.InGroup(SETTINGS_GROUP)
|
||||
.OptionalSecret(API_KEY_SETTING)
|
||||
.OptionalEnum(MARKET_SETTING, SUPPORTED_MARKETS)
|
||||
.InGroup(string.Empty);
|
||||
|
||||
public string GetSettingsGroupLabel() => TB("Staan");
|
||||
|
||||
public IReadOnlyList<ToolSettingsGroupLink> GetSettingsGroupLinks() =>
|
||||
[
|
||||
new(TB("Get an API key"), "https://staan.ai"),
|
||||
new(TB("Documentation"), "https://docs.staan.ai/docs/web-search"),
|
||||
];
|
||||
|
||||
public string GetSettingsFieldLabel(string fieldName) => fieldName switch
|
||||
{
|
||||
API_KEY_SETTING => TB("Staan API Key"),
|
||||
MARKET_SETTING => TB("Staan Market"),
|
||||
|
||||
_ => fieldName,
|
||||
};
|
||||
|
||||
public string GetSettingsFieldDescription(string fieldName) => fieldName switch
|
||||
{
|
||||
API_KEY_SETTING => TB("Your Staan API key. It is kept in your operating system's keyring, not in a settings file. Staan is a European search index; the first requests are free of charge, after which searching is billed per thousand requests."),
|
||||
MARKET_SETTING => TB("The market Staan searches in. Staan searches one market at a time and offers only these three. When the AI model asks for German, English, or French, the matching market is used no matter what is chosen here; this setting decides what happens for every other language and when no language is requested at all."),
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
public string? GetSettingsFieldDefaultValue(string fieldName) => fieldName switch
|
||||
{
|
||||
MARKET_SETTING => DEFAULT_MARKET,
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public bool IsConfigured(IReadOnlyDictionary<string, string> settingsValues) => !string.IsNullOrWhiteSpace(settingsValues.GetValueOrDefault(API_KEY_SETTING));
|
||||
|
||||
public bool TryValidateConfiguration(IReadOnlyDictionary<string, string> settingsValues, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
|
||||
//
|
||||
// The market is picked from a list in the dialog, but a stored value can come from an
|
||||
// organization's configuration. Staan answers an unknown market with a rejected
|
||||
// request, which would look like a broken API key:
|
||||
//
|
||||
var market = settingsValues.GetValueOrDefault(MARKET_SETTING);
|
||||
if (string.IsNullOrWhiteSpace(market) || IsSupportedMarket(market))
|
||||
return true;
|
||||
|
||||
error = string.Format(TB("The configured Staan market '{0}' is not one of the markets Staan offers. Please choose one of these: {1}."), market, string.Join(", ", SUPPORTED_MARKETS));
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<WebSearchBackendResult> SearchAsync(WebSearchQuery query, IReadOnlyDictionary<string, string> settingsValues, CancellationToken token = default)
|
||||
{
|
||||
var apiKey = settingsValues.GetValueOrDefault(API_KEY_SETTING);
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
throw new InvalidOperationException(TB("A Staan API key is required."));
|
||||
|
||||
//
|
||||
// Staan refuses a longer query outright. Shortening it here would search for something
|
||||
// other than what was asked, so the model is told and can search again instead:
|
||||
//
|
||||
if (query.Query.Length > MAX_QUERY_CHARACTERS)
|
||||
throw new InvalidOperationException($"Staan accepts a search query of at most {MAX_QUERY_CHARACTERS} characters, but this query has {query.Query.Length}. Search again with a shorter query.");
|
||||
|
||||
//
|
||||
// That Staan cannot restrict a search to a period of time is reported by the tool from
|
||||
// this backend's capabilities, so it is not repeated here. What stays here is the market,
|
||||
// because no capability flag can express which language Staan searched instead.
|
||||
//
|
||||
var notes = new List<string>();
|
||||
var market = ResolveMarket(query.Language, settingsValues, notes);
|
||||
|
||||
var searchRequest = new StaanSearchRequest { Query = query.Query, Market = market, Offset = ReadOffset(query.Page) };
|
||||
var response = await this.searchClient.SearchAsync(apiKey.Trim(), searchRequest, query.TimeoutSeconds, token);
|
||||
|
||||
//
|
||||
// Staan corrects an obvious mistake in a query and says so. Which query actually ran is
|
||||
// the difference between nothing matching the question and something else having been
|
||||
// asked, and only the correction explains a result set that does not fit the question:
|
||||
//
|
||||
var alteredQuery = response.Query?.AlteredQuery;
|
||||
if (!string.IsNullOrWhiteSpace(alteredQuery) && !string.Equals(alteredQuery, query.Query, StringComparison.Ordinal))
|
||||
notes.Add($"Staan corrected the query and searched for '{alteredQuery}' instead.");
|
||||
|
||||
var hits = (response.Web?.Results ?? []).Select(result => new SearchHit(result.Url, result.Title, result.Snippet));
|
||||
var candidates = SearchCandidateCollector.Collect(WebSearchBackend.STAAN, hits, query.Limit, out var candidateCount);
|
||||
return new WebSearchBackendResult(WebSearchBackend.STAAN, candidates, candidateCount, notes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The market to search in, from the language the tool asked for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Staan searches one market at a time, so a language it does not offer cannot simply be
|
||||
/// dropped the way an optional filter could: the search runs in some market either way, and
|
||||
/// results would arrive in another language than the one that was asked for. Saying so is
|
||||
/// what the note is for.
|
||||
/// </remarks>
|
||||
private static string ResolveMarket(string? language, IReadOnlyDictionary<string, string> settingsValues, List<string> notes)
|
||||
{
|
||||
var configuredMarket = settingsValues.GetValueOrDefault(MARKET_SETTING);
|
||||
var fallbackMarket = IsSupportedMarket(configuredMarket) ? configuredMarket!.Trim().ToLowerInvariant() : DEFAULT_MARKET;
|
||||
if (string.IsNullOrWhiteSpace(language) || string.Equals(language, ToolSettingsOptionSources.ANY_LANGUAGE, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
notes.Add($"Staan always searches one market and cannot search all of them at once, so it searched the '{fallbackMarket}' market.");
|
||||
return fallbackMarket;
|
||||
}
|
||||
|
||||
var market = MapLanguageToMarket(language);
|
||||
if (market is not null)
|
||||
return market;
|
||||
|
||||
notes.Add($"Staan offers no market for the language '{language}', so it searched the '{fallbackMarket}' market instead. The results are therefore not in the requested language.");
|
||||
return fallbackMarket;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Matched on the primary subtag, so that Austrian German reaches the German market and
|
||||
/// British English the English one. A market is a region as much as a language, so this
|
||||
/// trades the region away to keep the language.
|
||||
/// </remarks>
|
||||
private static string? MapLanguageToMarket(string language) => language.Split('-')[0].ToLowerInvariant() switch
|
||||
{
|
||||
"de" => MARKET_GERMANY,
|
||||
"en" => MARKET_UNITED_STATES,
|
||||
"fr" => MARKET_FRANCE,
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The offset addressing one result page.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Staan pages by offset instead of by page number, in steps of its fixed page size. The
|
||||
/// tool keeps the requested page within the maximum this backend reports, so nothing needs
|
||||
/// clamping here.
|
||||
/// </remarks>
|
||||
private static int? ReadOffset(int? page) => page is null or <= 1 ? null : (page.Value - 1) * RESULTS_PER_PAGE;
|
||||
|
||||
private static bool IsSupportedMarket(string? market) => !string.IsNullOrWhiteSpace(market) && SUPPORTED_MARKETS.Contains(market.Trim(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using AIStudio.Tools.Web;
|
||||
|
||||
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan;
|
||||
|
||||
/// <summary>
|
||||
/// Talks to Staan's search API.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing here knows the tool's settings or its result shape: the client sends one request,
|
||||
/// hands back what Staan answered, and turns a failure into a message that says what to do
|
||||
/// about it. How a search becomes Staan's parameters is the backend's part.
|
||||
/// </remarks>
|
||||
internal sealed class StaanSearchClient
|
||||
{
|
||||
private const string SEARCH_URL = "https://api.staan.ai/v2/search/web";
|
||||
|
||||
private const int MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
|
||||
public async Task<StaanSearchResponse> SearchAsync(string apiKey, StaanSearchRequest searchRequest, int timeoutSeconds, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await SearchInternalAsync(apiKey, searchRequest, timeoutSeconds, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or TimeoutException or InvalidOperationException or JsonException)
|
||||
{
|
||||
//
|
||||
// The reason has to travel with the message. It reaches the user through the tool
|
||||
// trace and the model through the tool result, and neither can act on "it failed":
|
||||
// a rejected key, an exhausted quota, and a rate limit all need different answers.
|
||||
//
|
||||
throw new InvalidOperationException($"The Staan search request failed: {exception.Message}", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<StaanSearchResponse> SearchInternalAsync(string apiKey, StaanSearchRequest searchRequest, int timeoutSeconds, CancellationToken token)
|
||||
{
|
||||
var searchUri = new Uri(SEARCH_URL);
|
||||
|
||||
//
|
||||
// Staan is a public service on the internet, so its certificate has to come from a root
|
||||
// the system trusts. Custom roots exist for a self-hosted search instance behind a
|
||||
// company's own certificate authority, which this is not:
|
||||
//
|
||||
using var httpClient = ExternalHttpClientTimeout.CreateHttpClient(searchUri, ExternalHttpTrustPolicy.SYSTEM_TRUST_ONLY);
|
||||
httpClient.Timeout = Timeout.InfiniteTimeSpan;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, searchUri)
|
||||
{
|
||||
Content = JsonContent.Create(searchRequest, options: WebSearchJson.OPTIONS),
|
||||
};
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
|
||||
using var response = await SendAsync(httpClient, request, timeoutCts.Token, timeoutSeconds, token);
|
||||
var responseBody = await HttpContentReader.ReadAsStringWithLimitAsync(response.Content, MAX_RESPONSE_BYTES, timeoutCts.Token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException(BuildStatusCodeMessage(response.StatusCode, responseBody));
|
||||
|
||||
StaanSearchResponse? searchResponse;
|
||||
try
|
||||
{
|
||||
searchResponse = JsonSerializer.Deserialize<StaanSearchResponse>(responseBody, WebSearchJson.OPTIONS);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new InvalidOperationException($"The Staan response was not valid JSON: {exception.Message}", exception);
|
||||
}
|
||||
|
||||
if (searchResponse is null)
|
||||
throw new InvalidOperationException("Staan answered with an empty response body.");
|
||||
|
||||
return searchResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a refused request means, in words the user and the model can act on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An exhausted quota is the one worth naming: it is the expected end of the free searches,
|
||||
/// and without the hint it would read as a broken search service.
|
||||
/// </remarks>
|
||||
private static string BuildStatusCodeMessage(HttpStatusCode statusCode, string responseBody)
|
||||
{
|
||||
var statusHint = statusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => " Staan refused the API key. Check whether the key is complete and still active.",
|
||||
HttpStatusCode.PaymentRequired => " The Staan account has no searches left. The free requests are used up, and paid usage has to be set up to continue.",
|
||||
HttpStatusCode.TooManyRequests => " Staan rate-limits this API key. Wait a moment before searching again.",
|
||||
HttpStatusCode.BadRequest => " Staan rejected the parameters of the request.",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
return $"Staan answered with status code {(int)statusCode} ({statusCode}).{statusHint}{SearchResponseExcerpt.CreateDetails(responseBody)}";
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Two cancellation tokens, so one of them cannot be the last parameter: the request token
|
||||
/// carries the search timeout, while the caller token says the user gave up. Telling them
|
||||
/// apart is what turns a cancellation into either a timeout message or a silent abort.
|
||||
/// </remarks>
|
||||
private static async Task<HttpResponseMessage> SendAsync(
|
||||
HttpClient httpClient,
|
||||
HttpRequestMessage request,
|
||||
CancellationToken requestToken,
|
||||
int timeoutSeconds,
|
||||
CancellationToken callerToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await httpClient.SendAsync(request, requestToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (!callerToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"The Staan request timed out after {timeoutSeconds} seconds.");
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw new InvalidOperationException($"The Staan request failed: {exception.Message}", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan;
|
||||
|
||||
/// <summary>
|
||||
/// The body of one Staan search request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only what this tool sends is declared. Staan also takes lists of domains to include or
|
||||
/// exclude, which this tool has no argument for, and the number of results per page is fixed
|
||||
/// at ten regardless of what a request asks for.
|
||||
/// </remarks>
|
||||
internal sealed record StaanSearchRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// What to search for. Staan rejects a query longer than 400 characters.
|
||||
/// </summary>
|
||||
[JsonPropertyName("q")]
|
||||
public required string Query { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The market to search in. Staan offers de-de, en-us, and fr-fr, and defaults to fr-fr.
|
||||
/// </summary>
|
||||
public string? Market { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Where in the result list to start, in steps of ten up to 30, or null for the first page.
|
||||
/// </summary>
|
||||
public int? Offset { get; init; }
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan;
|
||||
|
||||
/// <summary>
|
||||
/// What Staan answers to a search.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Declared down to what the tool reads. Staan also returns an identifier for the search and
|
||||
/// echoes the market, count, and offset it used; none of that reaches the user or the model,
|
||||
/// and a field nothing reads only raises the question of what it is for.
|
||||
/// </remarks>
|
||||
internal sealed record StaanSearchResponse
|
||||
{
|
||||
public StaanQueryInfo? Query { get; init; }
|
||||
|
||||
public StaanWebSection? Web { get; init; }
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan;
|
||||
|
||||
/// <summary>
|
||||
/// One web hit of a Staan search.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Staan also reports a shortened URL for display, the hostname, a favicon, and sometimes a
|
||||
/// thumbnail. All of it serves presenting a hit in a result list, while this tool loads and
|
||||
/// reads the page itself. Staan reports no publication date.
|
||||
/// </remarks>
|
||||
internal sealed record StaanSearchResult
|
||||
{
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
||||
public string Snippet { get; init; } = string.Empty;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan;
|
||||
|
||||
/// <summary>
|
||||
/// The web hits of a Staan search.
|
||||
/// </summary>
|
||||
internal sealed record StaanWebSection
|
||||
{
|
||||
public IReadOnlyList<StaanSearchResult> Results { get; init; } = [];
|
||||
}
|
||||
Reference in new issue
Block a user