using AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch; /// /// Decides which of the configured search services answer one search, and merges what they /// returned into a single ranked list. /// /// /// It owns the search backends as well, in the order of the backend enum, because that order /// is part of what it decides: a failover walks the services in it. Ordering them here rather /// than taking them as the dependency injection container happened to hand them over is what /// makes a search repeatable.

/// One service failing is not the search failing. Whichever strategy is running, a failure is /// kept as a note and the remaining services are still asked; only a search that no service /// answered is thrown, and then with every reason collected. The exception is the user /// cancelling: that ends the search at once, because nobody is waiting for its result any more. ///
internal sealed class WebSearchDispatcher(IEnumerable backends) { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WebSearchDispatcher).Namespace, nameof(WebSearchDispatcher)); public IReadOnlyList Backends { get; } = backends.OrderBy(backend => backend.Backend).ToList(); /// /// The services the user filled in enough of to be asked. /// public IReadOnlyList GetConfiguredBackends(IReadOnlyDictionary settingsValues) => this.Backends.Where(backend => backend.IsConfigured(settingsValues)).ToList(); public int CountConfiguredBackends(IReadOnlyDictionary settingsValues) => this.Backends.Count(backend => backend.IsConfigured(settingsValues)); public async Task SearchAsync(WebSearchBackendStrategy strategy, WebSearchBackend? primaryBackend, WebSearchQuery query, IReadOnlyDictionary settingsValues, CancellationToken token = default) { var notes = new List(); var backendsToAsk = this.ResolveBackendsToAsk(strategy, primaryBackend, query, settingsValues, notes); var outcomes = strategy is WebSearchBackendStrategy.PARALLEL ? await SearchInParallelAsync(backendsToAsk, query, settingsValues, token) : await SearchOneAfterAnotherAsync(backendsToAsk, query, settingsValues, token); AppendBackendNotes(notes, outcomes, query); var backendResults = outcomes.Select(outcome => outcome.Result).OfType().ToList(); // // Nothing to report and nothing to search with: the notes hold every reason, so they // travel in the message rather than in a result nobody will get: // if (backendResults.Count is 0) throw new InvalidOperationException($"{TB("None of the configured search services could be asked.")} {string.Join(" ", notes)}"); return new WebSearchDispatchResult( backendResults.Select(result => result.Backend).ToList(), MergeCandidates(backendResults, query.Limit), backendResults.Sum(result => result.CandidateCount), notes); } /// /// Which services to ask, in which order. /// /// /// A stored choice that no longer fits what is configured does not stop the search: it is /// reported as a note and the search runs with what is there. Both meta settings are hidden /// while fewer than two services are configured, so such a value can outlive the situation /// it was made for, and a search refusing to run over one would leave the user with nothing /// they can act on. /// private IReadOnlyList ResolveBackendsToAsk(WebSearchBackendStrategy strategy, WebSearchBackend? primaryBackend, WebSearchQuery query, IReadOnlyDictionary settingsValues, List notes) { var configuredBackends = this.GetConfiguredBackends(settingsValues); if (configuredBackends.Count is 0) throw new InvalidOperationException(TB("No search service is configured for the web search.")); // // Only the strategies that ask one service before the others have a use for the chosen // one. Asking all of them at once has none, which is also why the dialog hides the // choice then: // var usesChosenBackend = strategy is WebSearchBackendStrategy.FAILOVER or WebSearchBackendStrategy.SPECIFIC; var chosenBackend = usesChosenBackend ? configuredBackends.FirstOrDefault(backend => backend.Backend == primaryBackend) : null; if (usesChosenBackend && chosenBackend is null && configuredBackends.Count > 1) { if (primaryBackend is not null) notes.Add($"The chosen search service {primaryBackend.Value.ToName()} is not configured, so the configured services were asked one after another instead."); else if (strategy is WebSearchBackendStrategy.SPECIFIC) notes.Add("No search service is chosen, so the configured services were asked one after another instead."); } List backendsToAsk; if (strategy is WebSearchBackendStrategy.SPECIFIC && chosenBackend is not null) backendsToAsk = [chosenBackend]; else if (chosenBackend is null) backendsToAsk = [..configuredBackends]; else backendsToAsk = [chosenBackend, ..configuredBackends.Where(backend => backend != chosenBackend)]; var backendsThatCanFilter = RemoveBackendsWithoutSafeSearch(backendsToAsk, query, notes); return RemoveBackendsWithoutThisPage(backendsThatCanFilter, query, notes); } /// /// Drops the services that cannot apply the configured safe search policy. /// /// /// The policy belongs to the user, and an organization can lock it. A service that cannot /// filter would answer with unfiltered hits, which is the one thing the policy exists to /// prevent, so it is not asked — however good its results would have been.

/// A policy that leaves no service at all is a matter of the settings rather than of this /// search, and the settings report it before it comes to this. Reaching it here means the /// settings changed since, so it says what to change rather than what failed. ///
private static IReadOnlyList RemoveBackendsWithoutSafeSearch(IReadOnlyList backendsToAsk, WebSearchQuery query, List notes) { if (query.SafeSearch is null or SafeSearchPolicy.OFF) return backendsToAsk; var remainingBackends = backendsToAsk.Where(backend => backend.Capabilities.SupportsSafeSearch).ToList(); if (remainingBackends.Count is 0) throw new InvalidOperationException(TB("None of the search services this search would use can filter explicit results, which the configured safe search policy requires. Please configure a search service that can filter, or turn the policy off.")); foreach (var backend in backendsToAsk.Where(backend => !backend.Capabilities.SupportsSafeSearch)) notes.Add($"{backend.Backend.ToName()} was not asked, because it cannot filter explicit results and the configured safe search policy requires that."); return remainingBackends; } /// /// Drops the services that cannot serve the requested result page. /// /// /// Answering page 1 where page 3 was asked for would look right and be wrong: the model /// would read the same hits a second time without any way to notice. Leaving the service /// out is the honest answer, and the note says which one dropped out. /// private static IReadOnlyList RemoveBackendsWithoutThisPage(IReadOnlyList backendsToAsk, WebSearchQuery query, List notes) { if (query.Page is null or <= 1) return backendsToAsk; var remainingBackends = backendsToAsk.Where(backend => query.Page <= backend.Capabilities.MaxPage).ToList(); if (remainingBackends.Count is 0) throw new ArgumentException($"Argument 'page' must be less than or equal to {backendsToAsk.Max(backend => backend.Capabilities.MaxPage)}."); foreach (var backend in backendsToAsk.Where(backend => query.Page > backend.Capabilities.MaxPage)) notes.Add($"{backend.Backend.ToName()} was not asked, because it does not serve result page {query.Page}."); return remainingBackends; } /// /// The first service that returns a hit ends the search; everything after it is there for /// the case that the ones before it answered nothing. Each of them gets the full search /// timeout, so a search across three unreachable services takes three times as long as one /// — that is the price of a failover, and the reason the timeout is a setting. /// private static async Task> SearchOneAfterAnotherAsync(IReadOnlyList backendsToAsk, WebSearchQuery query, IReadOnlyDictionary settingsValues, CancellationToken token) { var outcomes = new List(); foreach (var backend in backendsToAsk) { var outcome = await SearchOneAsync(backend, query, settingsValues, token); outcomes.Add(outcome); if (outcome.Result is { Candidates.Count: > 0 }) break; } return outcomes; } /// /// Every service is asked, and every service costs a request of whatever it grants for /// free. That is what the user chose this strategy for: two indexes see different parts of /// the web, and a hit both of them found is a stronger hit than one only one of them had. /// private static async Task> SearchInParallelAsync(IReadOnlyList backendsToAsk, WebSearchQuery query, IReadOnlyDictionary settingsValues, CancellationToken token) => await Task.WhenAll(backendsToAsk.Select(backend => SearchOneAsync(backend, query, settingsValues, token))); /// /// Everything a service can go wrong with is caught here, not just the failures its own /// client words: a backend is free to throw whatever describes its situation, and one of /// them throwing must not take the search down with it. The user cancelling is the one /// thing that does, which is why the filter asks the token rather than the exception type. /// private static async Task SearchOneAsync(IWebSearchBackend backend, WebSearchQuery query, IReadOnlyDictionary settingsValues, CancellationToken token) { try { return new(backend, await backend.SearchAsync(query, settingsValues, token), null); } catch (Exception exception) when (!token.IsCancellationRequested) { return new(backend, null, exception.Message); } } /// /// Collects what the services reported besides their hits. /// /// /// A note says which service it came from as soon as more than one was asked, and does not /// while only one was: a search through a single service has nobody to be confused with, /// and its notes already name it where that matters. /// private static void AppendBackendNotes(List notes, IReadOnlyList outcomes, WebSearchQuery query) { var attributesNotes = outcomes.Count > 1; foreach (var outcome in outcomes) { var backendName = outcome.Backend.Backend.ToName(); var result = outcome.Result; if (result is null) { notes.Add($"{backendName} could not be asked: {outcome.Error}"); continue; } AppendUnsupportedFilterNotes(notes, outcome.Backend, query); if (attributesNotes && result.Candidates.Count is 0) notes.Add($"{backendName} returned no hits."); foreach (var note in result.Notes) notes.Add(attributesNotes ? $"{backendName}: {note}" : note); } } /// /// Says which of the filters the model asked for a service could not apply. /// /// /// The model asked for these, and it can read the answer and search again, so a service /// that cannot honour one of them is asked anyway and reports what it did instead. Hits /// from last year read exactly like hits from last week, which is what makes the silence /// worse than the missing filter.

/// Only for a service that was really asked, which is why this is not part of choosing /// them: in a failover most of the chosen services are never reached, and a note about one /// of those explains nothing about the answer. ///
private static void AppendUnsupportedFilterNotes(List notes, IWebSearchBackend backend, WebSearchQuery query) { var backendName = backend.Backend.ToName(); var capabilities = backend.Capabilities; if (!capabilities.SupportsTimeRange && !string.IsNullOrWhiteSpace(query.TimeRange)) notes.Add($"{backendName} cannot restrict a search to a period of time, so its hits are not limited to the requested time range '{query.TimeRange}'."); if (!capabilities.SupportsLanguage && HasLanguageRestriction(query)) notes.Add($"{backendName} cannot restrict a search to one language, so its hits can be in any language rather than in '{query.Language}'."); } private static bool HasLanguageRestriction(WebSearchQuery query) => !string.IsNullOrWhiteSpace(query.Language) && !string.Equals(query.Language, ToolSettingsOptionSources.ANY_LANGUAGE, StringComparison.OrdinalIgnoreCase); /// /// Merges the hits of several services into one ranked list. /// /// /// The services are read in step: the first hit of each of them, then the second hit of /// each, and so on. Their own scores cannot be compared — every engine computes a different /// number and none of them is published — so the position each service gave a hit is all /// there is to go by, and giving each service the same say at every position is the only /// merge that does not quietly favour one of them.

/// The same page found by two services becomes one candidate that names both, and the /// limit applies to that merged list rather than to each service, so the tool retrieves as /// many pages as it would for a single service. ///
private static IReadOnlyList MergeCandidates(IReadOnlyList backendResults, int limit) { // One service needs no merging, and its candidates are limited and ranked already: if (backendResults.Count is 1) return backendResults[0].Candidates; var candidatesByUrl = new Dictionary(StringComparer.Ordinal); var mergedCandidates = new List(); var mostCandidatesOfOneBackend = backendResults.Max(result => result.Candidates.Count); for (var position = 0; position < mostCandidatesOfOneBackend; position++) { foreach (var backendResult in backendResults) { if (position >= backendResult.Candidates.Count) continue; var candidate = backendResult.Candidates[position]; var normalizedUrl = SearchCandidate.NormalizeUrl(candidate.RetrievalUrl); if (candidatesByUrl.TryGetValue(normalizedUrl, out var existingCandidate)) { existingCandidate.Merge(candidate); continue; } // Cloned, because merging writes to the candidate, and the result a backend // handed over is not ours to change: var mergedCandidate = candidate.Clone(); candidatesByUrl[normalizedUrl] = mergedCandidate; mergedCandidates.Add(mergedCandidate); } } // // The ranks the services gave are gone at this point, and the merged order is what // replaces them. Renumbering says so, and keeps the ranks the tool reports a plain // 1, 2, 3 rather than a mix of two services' numbering: // var limitedCandidates = mergedCandidates.Take(limit).ToList(); for (var index = 0; index < limitedCandidates.Count; index++) limitedCandidates[index].Rank = index + 1; return limitedCandidates; } /// The service that was asked. /// What it answered, or null when it could not be asked. /// Why it could not be asked, or null when it answered. private sealed record BackendOutcome(IWebSearchBackend Backend, WebSearchBackendResult? Result, string? Error); }