using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Databases;
using AIStudio.Tools.Databases.IndexStore;
using AIStudio.Tools.Databases.VectorStore;
using AIStudio.Tools.Mail;
using AIStudio.Tools.RAG;
namespace AIStudio.Tools.Services;
///
/// Searches, counts and reads the mails of the mailboxes, for the mail tools.
///
///
/// Every way into a mailbox goes through here, and each one checks on its own whether the provider
/// of the chat may read the mailbox. The tools offer only the mailboxes it may read, but the settings
/// may change between preparing a request and running one of its calls.
/// Everything comes from the index, nothing from the server, so nothing here signs in anywhere. The
/// log names a mailbox, but never a subject, an address, or a folder.
///
public sealed class MailboxRetrievalService(SettingsManager settingsManager, DatabaseClientProvider databaseClientProvider, LocalIndexSearchService indexSearch, ILogger logger)
{
///
/// How many chunks each channel delivers for every mail a page needs.
///
///
/// The channels find chunks, a page lists mails. A mail which matches tends to match with several
/// of its chunks, its header block and its text, maybe an attachment as well, so a window of as
/// many chunks as mails would come up short. Paging stops at RetrievalPaging.MAX_RESULT_WINDOW
/// mails, which makes four hundred chunks per channel at most.
///
private const int CHUNKS_PER_MAIL = 4;
///
/// Whether mailboxes exist in this installation right now: both of their previews are switched on.
///
///
/// Mailboxes are a preview of their own, on top of local RAG. The mail tools are available
/// exactly as long as this holds, see IToolImplementation.IsAvailable.
///
public bool AreMailboxesEnabled => PreviewFeatures.PRE_RAG_2024.IsEnabled(settingsManager) && PreviewFeatures.PRE_MAILBOXES_2026.IsEnabled(settingsManager);
///
/// The mailboxes which the provider of a chat may read, in the order the tools offer them.
///
///
/// The same mailboxes always come in the same order, by their number and then by their id: the
/// providers cache a request from its beginning, and the tools describing the mailboxes are part
/// of it, cf. SemanticSearchTool.InOfferOrder. A mailbox on a server the organization does not
/// allow is left out, although its index is still there, cf. MailServerPolicy.
///
/// How much the provider of the chat is trusted.
/// The mailboxes, none while one of the previews is switched off.
public IReadOnlyList GetReadableMailboxes(ConfidenceLevel chatProviderConfidence)
{
if (!this.AreMailboxesEnabled)
return [];
var policy = MailServerPolicy.Read(settingsManager);
return settingsManager.ConfigurationData.Mailboxes
.Where(mailbox => policy.IsAllowed(mailbox.Host))
.Where(mailbox => IsReadable(mailbox.ConfidenceLevel, chatProviderConfidence, this.GetEmbeddingProviderConfidence(mailbox)))
.OrderBy(mailbox => mailbox.Num)
.ThenBy(mailbox => mailbox.Id, StringComparer.Ordinal)
.ToList();
}
///
/// Whether the providers which see the content of a mailbox may see it.
///
///
/// Two providers see it while it is searched: the provider of the chat reads what is found, and
/// the embedding provider gets the query, which the model may well have written from a mail. The
/// embedding provider has to meet the level even though it embedded the mails long before: its
/// confidence may have been lowered since. A mailbox whose embedding provider is gone cannot be
/// read at all, because nobody can tell whether a replacement would meet the level.
///
/// The confidence level the mailbox requires.
/// How much the provider of the chat is trusted.
/// How much the embedding provider of the mailbox is trusted, or null when it is not available.
/// True when the mailbox may be read.
internal static bool IsReadable(ConfidenceLevel mailboxConfidence, ConfidenceLevel chatProviderConfidence, ConfidenceLevel? embeddingProviderConfidence) =>
embeddingProviderConfidence is { } embeddingConfidence
&& chatProviderConfidence.AllowsMailboxConfidenceLevel(mailboxConfidence)
&& embeddingConfidence.AllowsMailboxConfidenceLevel(mailboxConfidence);
///
/// Reads how far the index of a mailbox reaches.
///
/// How much the provider of the chat is trusted.
/// The mailbox.
/// The cancellation token.
/// The coverage, or null when the index cannot be read right now.
/// The mailbox is not configured, or the provider of the chat may not read it.
public async Task GetCoverageAsync(ConfidenceLevel chatProviderConfidence, string mailboxId, CancellationToken token)
{
var mailbox = this.RequireReadableMailbox(mailboxId, chatProviderConfidence);
try
{
var indexStore = await databaseClientProvider.GetIndexStoreAsync(token);
if (!indexStore.IsAvailable)
{
logger.LogWarning("Cannot tell how far the index of mailbox '{MailboxName}' ({MailboxId}) reaches, because local RAG index '{DatabaseName}' is unavailable.", mailbox.Name, mailbox.Id, indexStore.Name);
return null;
}
return await ReadCoverageAsync(indexStore, mailbox, DateTimeOffset.UtcNow, token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Cannot tell how far the index of mailbox '{MailboxName}' ({MailboxId}) reaches.", mailbox.Name, mailbox.Id);
return null;
}
}
/// The index store, which has to be available.
/// The mailbox.
/// The point in time the period of the mailbox is counted back from.
/// The cancellation token.
internal static async Task ReadCoverageAsync(IndexStoreClient indexStore, DataSourceMailbox mailbox, DateTimeOffset now, CancellationToken token)
{
var syncState = await indexStore.GetMailboxSyncStateAsync(mailbox.Id, token);
var refusedSignIn = await indexStore.GetMailboxAuthFailureAsync(mailbox.Id, token);
var folders = await indexStore.GetMailFoldersAsync(mailbox.Id, token);
return new(mailbox.MaxAge.GetReceivedSince(now), syncState.LastSyncCompletedUtc, refusedSignIn?.FailedAtUtc, syncState.PendingRemovalCount, folders);
}
///
/// Searches one page of the mails of a mailbox which meet the conditions.
///
///
/// With a query, the mails are searched by meaning and by words, and each mail comes with the
/// passage which matched best. Without one, the mails which meet the conditions are listed, the
/// most recently received first. Either way, a page holds the mails of the page of each channel
/// and is cut the same way, see RetrievalPaging. The query comes from the model, so its problems
/// are reported to the model and not to the user.
///
/// How much the provider of the chat is trusted.
/// The mailbox.
/// What to search for, or null to list the mails.
/// The conditions the mails have to meet.
/// The page, starting at 1, up to RetrievalPaging.GetLastPage for the page size of the mailbox.
/// The cancellation token.
/// The page; empty with a gap when the mailbox could not be searched.
/// The mailbox is not configured, or the provider of the chat may not read it.
/// The page is below 1 or beyond the last page.
public async Task SearchAsync(ConfidenceLevel chatProviderConfidence, string mailboxId, string? query, MailFilter filter, int page, CancellationToken token)
{
var mailbox = this.RequireReadableMailbox(mailboxId, chatProviderConfidence);
var pageSize = (int)mailbox.MaxMatches;
// Checks the page before anything is searched:
_ = RetrievalPaging.GetWindowSize(page, pageSize);
if (pageSize == 0)
return MailSearchPage.EMPTY;
var run = new RetrievalRun(queryWrittenByUser: false);
if (await indexSearch.IsAwaitingReindexAsync(mailbox, run, token))
return MailSearchPage.EMPTY with { Gaps = run.GetGaps() };
var byRelevance = !string.IsNullOrWhiteSpace(query);
try
{
var indexStore = await databaseClientProvider.GetIndexStoreAsync(token);
if (!indexStore.IsAvailable)
{
logger.LogWarning("Skipping the search of mailbox '{MailboxName}' ({MailboxId}) because local RAG index '{DatabaseName}' is unavailable.", mailbox.Name, mailbox.Id, indexStore.Name);
run.Add(RetrievalGap.NOT_SEARCHED);
return MailSearchPage.EMPTY with { Gaps = run.GetGaps() };
}
var result = string.IsNullOrWhiteSpace(query)
? await ListNewestFirstAsync(indexStore, mailbox.Id, filter, page, pageSize, token)
: await this.SearchByRelevanceAsync(indexStore, mailbox, query, filter, page, pageSize, run, token);
var gaps = run.GetGaps();
logger.LogInformation(
"Searched mailbox '{MailboxName}' ({MailboxId}). ByRelevance={ByRelevance}, Page={Page}, Mails={MailCount}, HasMore={HasMore}, Gaps=[{Gaps}].",
mailbox.Name,
mailbox.Id,
byRelevance,
page,
result.Hits.Count,
result.HasMore,
string.Join(", ", gaps));
return result with { Gaps = gaps };
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Searching mailbox '{MailboxName}' ({MailboxId}) failed. ByRelevance={ByRelevance}.", mailbox.Name, mailbox.Id, byRelevance);
run.Add(RetrievalGap.NOT_SEARCHED);
return MailSearchPage.EMPTY with { Gaps = run.GetGaps() };
}
}
///
/// Lists one page of the mails which meet the conditions, the most recently received first.
///
///
/// The window of the page is fetched from the start, as with a search by relevance, although
/// the index could skip to the page directly. That keeps one rule for how far a mailbox can be
/// paged through and when a further page is worth asking for, whichever way it is searched.
///
/// The index store, which has to be available.
/// The mailbox.
/// The conditions.
/// The page, starting at 1.
/// How many mails a page lists.
/// The cancellation token.
internal static async Task ListNewestFirstAsync(IndexStoreClient indexStore, string mailboxId, MailFilter filter, int page, int pageSize, CancellationToken token)
{
var window = await indexStore.QueryMailsAsync(mailboxId, filter, 0, RetrievalPaging.GetWindowSize(page, pageSize), token);
var (mailIds, hasMore) = RetrievalPaging.Cut(window, page, pageSize);
var summaries = await indexStore.GetMailSummariesAsync(mailboxId, mailIds, token);
return new(summaries.Select(summary => new MailSearchHit(summary, null)).ToList(), hasMore, []);
}
private async Task SearchByRelevanceAsync(IndexStoreClient indexStore, DataSourceMailbox mailbox, string query, MailFilter filter, int page, int pageSize, RetrievalRun run, CancellationToken token)
{
var chunkWindow = RetrievalPaging.GetWindowSize(page, pageSize) * CHUNKS_PER_MAIL;
//
// Without a condition, every chunk of the collection may match, and the collection holds
// nothing but this mailbox. Sending the ids of all its chunks along would only make the
// request large, so the whole collection is searched, and the summaries leave out the few
// mails which lost their last place on the server since the last sync. With a condition,
// the vector search sees the chunks of the mails which meet it, and nothing else:
//
var vectorFilter = filter.HasConditions ? new VectorSearchFilter(await indexStore.GetMailChunkIdsAsync(mailbox.Id, filter, token)) : null;
var vectorTask = indexSearch.SearchVectorsAsync(mailbox, query, chunkWindow, vectorFilter, run, token);
var keywordTask = indexSearch.SearchKeywordsAsync(mailbox, chunkWindow, store => store.SearchMailChunksAsync(mailbox.Id, query, filter, chunkWindow, token), run, token);
await Task.WhenAll(vectorTask, keywordTask);
token.ThrowIfCancellationRequested();
var vectorChunks = vectorTask.Result;
var keywordChunks = keywordTask.Result;
var (passages, hasMore) = RetrievalPaging.Merge(
BestPassagePerMail(vectorChunks.Select(chunk => new MailPassage(chunk.ParentFileId, chunk.Text))),
BestPassagePerMail(keywordChunks.Select(chunk => new MailPassage(chunk.ParentFileId, chunk.ChunkText))),
passage => passage.MailId,
page,
pageSize);
hasMore = hasMore || MayHoldMoreMails(page, pageSize, chunkWindow, vectorChunks.Count, keywordChunks.Count);
var passageTexts = passages
.DistinctBy(passage => passage.MailId, StringComparer.Ordinal)
.ToDictionary(passage => passage.MailId, passage => passage.Text, StringComparer.Ordinal);
var summaries = await indexStore.GetMailSummariesAsync(mailbox.Id, passages.Select(passage => passage.MailId).ToList(), token);
return new(summaries.Select(summary => new MailSearchHit(summary, passageTexts[summary.MailId])).ToList(), hasMore, []);
}
///
/// Keeps the best chunk of every mail, which turns a list of chunks into a list of mails.
///
/// The chunks a channel found, the best first.
/// One passage per mail, in the order of their best chunks.
internal static IReadOnlyList BestPassagePerMail(IEnumerable chunks)
{
var seenMails = new HashSet(StringComparer.Ordinal);
return chunks.Where(chunk => !string.IsNullOrWhiteSpace(chunk.MailId) && seenMails.Add(chunk.MailId)).ToList();
}
///
/// Whether a channel may hold further mails which its window of chunks did not reach.
///
///
/// A channel which filled its whole window may have more to show, even when its chunks belong
/// to fewer mails than the page needed. Saying there is more when there is not costs one page
/// which turns out empty; saying the opposite would hide mails.
///
/// The page, starting at 1.
/// How many mails a page lists per channel.
/// How many chunks each channel was asked for.
/// How many chunks each channel delivered.
/// True when the next page is worth asking for.
internal static bool MayHoldMoreMails(int page, int pageSize, int chunkWindow, params int[] chunkCounts) =>
page < RetrievalPaging.GetLastPage(pageSize) && chunkCounts.Any(count => count >= chunkWindow);
///
/// Counts the mails of a mailbox which meet the conditions.
///
/// How much the provider of the chat is trusted.
/// The mailbox.
/// The conditions.
/// How to break the number down.
/// How many groups to return at most, the largest ones.
/// The cancellation token.
/// The count; without a number and with a gap when the mailbox could not be counted.
/// The mailbox is not configured, or the provider of the chat may not read it.
public async Task CountAsync(ConfidenceLevel chatProviderConfidence, string mailboxId, MailFilter filter, MailCountGrouping grouping, int maxGroups, CancellationToken token)
{
var mailbox = this.RequireReadableMailbox(mailboxId, chatProviderConfidence);
// While the index is built anew, it holds only part of the mails, and any number would be too low:
var run = new RetrievalRun(queryWrittenByUser: false);
if (await indexSearch.IsAwaitingReindexAsync(mailbox, run, token))
return new(null, run.GetGaps());
try
{
var indexStore = await databaseClientProvider.GetIndexStoreAsync(token);
if (!indexStore.IsAvailable)
{
logger.LogWarning("Skipping the count of mailbox '{MailboxName}' ({MailboxId}) because local RAG index '{DatabaseName}' is unavailable.", mailbox.Name, mailbox.Id, indexStore.Name);
return new(null, [RetrievalGap.NOT_SEARCHED]);
}
var count = await indexStore.CountMailsAsync(mailbox.Id, filter, grouping, maxGroups, token);
logger.LogInformation("Counted mailbox '{MailboxName}' ({MailboxId}). Grouping={Grouping}, Total={TotalCount}, Groups={GroupCount}.", mailbox.Name, mailbox.Id, grouping, count.TotalCount, count.Groups.Count);
return new(count, []);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Counting mailbox '{MailboxName}' ({MailboxId}) failed. Grouping={Grouping}.", mailbox.Name, mailbox.Id, grouping);
return new(null, [RetrievalGap.NOT_SEARCHED]);
}
}
///
/// Reads a mail from whichever mailbox holds it, among those the provider of the chat may read.
///
///
/// The id of a mail is derived from the id of its mailbox, so no two mailboxes share one. A mail
/// of a mailbox the provider may not read is not found, exactly like a mail nobody ever indexed.
///
/// How much the provider of the chat is trusted.
/// The id of the mail.
/// The cancellation token.
/// The mail, or null when no mailbox the provider may read holds it.
/// The index cannot be read right now, so nobody can tell whether the mail exists.
public async Task ReadAsync(ConfidenceLevel chatProviderConfidence, string mailId, CancellationToken token)
{
var mailboxes = this.GetReadableMailboxes(chatProviderConfidence);
if (mailboxes.Count == 0)
return null;
var indexStore = await databaseClientProvider.GetIndexStoreAsync(token);
if (!indexStore.IsAvailable)
throw new InvalidOperationException($"No mail can be read, because local RAG index '{indexStore.Name}' is unavailable.");
return await ReadMailAsync(indexStore, mailboxes, mailId, token);
}
///
/// A mail which lost its last place on the server is not read: it is gone, or about to turn up
/// under another id once the next sync found where it went.
///
/// The index store, which has to be available.
/// The mailboxes to look in.
/// The id of the mail.
/// The cancellation token.
internal static async Task ReadMailAsync(IndexStoreClient indexStore, IReadOnlyList mailboxes, string mailId, CancellationToken token)
{
foreach (var mailbox in mailboxes)
{
var summaries = await indexStore.GetMailSummariesAsync(mailbox.Id, [mailId], token);
if (summaries.Count == 0)
continue;
var mail = await indexStore.GetMailAsync(mailbox.Id, mailId, token);
if (mail is null)
continue;
var inReplyToMailId = await indexStore.FindMailByMessageIdAsync(mailbox.Id, mail.InReplyTo, token);
return new(mailbox, summaries[0], mail, inReplyToMailId == mailId ? null : inReplyToMailId);
}
return null;
}
private DataSourceMailbox RequireReadableMailbox(string mailboxId, ConfidenceLevel chatProviderConfidence)
{
foreach (var mailbox in this.GetReadableMailboxes(chatProviderConfidence))
if (mailbox.Id == mailboxId)
return mailbox;
logger.LogWarning("The mailbox '{MailboxId}' is not configured, the provider of the chat may not read it, or the organization does not allow its server. Its mails stay closed.", mailboxId);
throw new MailboxNotReadableException(mailboxId);
}
private ConfidenceLevel? GetEmbeddingProviderConfidence(DataSourceMailbox mailbox) =>
DataSourceEmbeddingProviders.TryResolve(settingsManager, mailbox, out var embeddingProvider) ? embeddingProvider.GetConfidenceLevel(settingsManager) : null;
}