mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-10 16:47:22 +00:00
225 lines
12 KiB
C#
225 lines
12 KiB
C#
using System.Text.Json.Nodes;
|
|
|
|
using AIStudio.Chat;
|
|
using AIStudio.Settings.DataModel;
|
|
using AIStudio.Tools.Databases;
|
|
using AIStudio.Tools.Databases.IndexStore;
|
|
using AIStudio.Tools.Mail;
|
|
using AIStudio.Tools.RAG;
|
|
using AIStudio.Tools.Services;
|
|
|
|
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.Mailboxes;
|
|
|
|
/// <summary>
|
|
/// A mail found, with its texts waiting to be filtered.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Whether the mail lies among the sent mails or the drafts is AI Studio's own value, so it needs no filtering.
|
|
/// </para>
|
|
/// <para>
|
|
/// A result shows only a part of a mail: every text has a limit, and so has every list. Together,
|
|
/// they keep a single mail far below a page of the search, see SearchMailsTool.MAX_RESULT_CHARACTERS,
|
|
/// even when every character has to be escaped in JSON. Otherwise, a mail with hundreds of
|
|
/// attachments would fill a page on its own. read_mail shows the whole mail.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed record PendingMail(DataSourceMailbox Mailbox, MailSummary Summary, int Subject, int From, int Sender, IReadOnlyList<int> Recipients, int MoreRecipients, IReadOnlyList<int> Folders, int MoreFolders, string? SpecialFolder, IReadOnlyList<int> Attachments, int MoreAttachments, int? Passage, ChunkLocation? PassageLocation, int? PassageAttachment)
|
|
{
|
|
/// <summary>
|
|
/// How much of the passage which matched best a result shows.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Enough to tell whether a mail answers the question. A chunk can be tens of thousands of
|
|
/// characters long, and a page lists several mails per mailbox, so a whole chunk each would use
|
|
/// up the budget after a few mails.
|
|
/// </remarks>
|
|
private const int MAX_PASSAGE_CHARACTERS = 1_500;
|
|
|
|
/// <summary>
|
|
/// How much of a subject a result shows. Subjects are short, unless somebody wrote a letter into one.
|
|
/// </summary>
|
|
private const int MAX_SUBJECT_CHARACTERS = 300;
|
|
|
|
/// <summary>
|
|
/// How many recipients of a mail a result names. A mail to a large list would otherwise fill the result.
|
|
/// </summary>
|
|
private const int MAX_LISTED_RECIPIENTS = 5;
|
|
|
|
/// <summary>
|
|
/// How many folders of a mail a result names. A mail lies in one folder, as a rule, but a server
|
|
/// which shows labels as folders may put it into many.
|
|
/// </summary>
|
|
private const int MAX_LISTED_FOLDERS = 10;
|
|
|
|
/// <summary>
|
|
/// How many attachments of a mail a result names, as many as the index lists in the text of a mail, cf. MailTextBuilder.
|
|
/// </summary>
|
|
private const int MAX_LISTED_ATTACHMENTS = 20;
|
|
|
|
/// <summary>
|
|
/// How much of an address, of the name of a sender, of a folder, or of the name of an attachment a result shows.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// As much as of a subject. Nothing keeps a sender from writing a display name or a file name of any length.
|
|
/// </remarks>
|
|
private const int MAX_NAME_CHARACTERS = MAX_SUBJECT_CHARACTERS;
|
|
|
|
/// <summary>
|
|
/// Registers everything a result shows of a mail with the texts to filter.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The attachment the passage is from is named even when the list of attachments ends before
|
|
/// it. Otherwise, the model would take the passage for one of the text of the mail.
|
|
/// </remarks>
|
|
/// <param name="hit">The mail found.</param>
|
|
/// <param name="mailbox">The mailbox it lies in.</param>
|
|
/// <param name="mailboxFolders">The folders of the mailbox, to tell the sent mails and the drafts.</param>
|
|
/// <param name="texts">The texts of the result, to be filtered together.</param>
|
|
/// <returns>The mail, waiting for its texts to be filtered.</returns>
|
|
public static PendingMail Register(MailSearchHit hit, DataSourceMailbox mailbox, IReadOnlyList<MailFolderRecord> mailboxFolders, MailTexts texts)
|
|
{
|
|
var summary = hit.Summary;
|
|
var sender = MailToolResults.FindSender(summary.Addresses);
|
|
var recipients = summary.Addresses.Where(address => address.Role is MailAddressRole.TO or MailAddressRole.CC).ToList();
|
|
var attachments = summary.AttachmentNames.Take(MAX_LISTED_ATTACHMENTS).Select(name => texts.Add(name.Shorten(MAX_NAME_CHARACTERS), mailbox)).ToList();
|
|
|
|
int? passageAttachment = null;
|
|
if (hit is { Passage: not null, PassageLocation.AttachmentPosition: { } position } && position >= 0 && position < summary.AttachmentNames.Count)
|
|
passageAttachment = position < attachments.Count ? attachments[position] : texts.Add(summary.AttachmentNames[position].Shorten(MAX_NAME_CHARACTERS), mailbox);
|
|
|
|
return new(
|
|
mailbox,
|
|
summary,
|
|
texts.Add(string.IsNullOrWhiteSpace(summary.Subject) ? MailToolResults.NO_SUBJECT : summary.Subject.Shorten(MAX_SUBJECT_CHARACTERS), mailbox),
|
|
texts.Add(sender is null ? MailToolResults.UNKNOWN_SENDER : MailToolResults.FormatAddress(sender).Shorten(MAX_NAME_CHARACTERS), mailbox),
|
|
texts.Add(MailToolResults.GetSenderName(sender).Shorten(MAX_NAME_CHARACTERS), mailbox),
|
|
recipients.Take(MAX_LISTED_RECIPIENTS).Select(recipient => texts.Add(MailToolResults.FormatAddress(recipient).Shorten(MAX_NAME_CHARACTERS), mailbox)).ToList(),
|
|
Math.Max(0, recipients.Count - MAX_LISTED_RECIPIENTS),
|
|
summary.FolderPaths.Take(MAX_LISTED_FOLDERS).Select(folder => texts.Add(folder.Shorten(MAX_NAME_CHARACTERS), mailbox)).ToList(),
|
|
Math.Max(0, summary.FolderPaths.Count - MAX_LISTED_FOLDERS),
|
|
MailToolResults.GetSpecialFolder(summary.FolderPaths, mailboxFolders),
|
|
attachments,
|
|
Math.Max(0, summary.AttachmentNames.Count - MAX_LISTED_ATTACHMENTS),
|
|
hit.Passage is null ? null : texts.Add(hit.Passage.Shorten(MAX_PASSAGE_CHARACTERS), mailbox),
|
|
hit.PassageLocation,
|
|
passageAttachment);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes what the model and the user get of this mail, once its texts went through the filter.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The mail is measured as the model receives it, so that a search can tell whether it fits.
|
|
/// Mails are listed without a link, so what the model received of one is all there is to read
|
|
/// under it.
|
|
/// </remarks>
|
|
/// <param name="texts">The filtered texts of the result.</param>
|
|
/// <param name="timeZone">The time zone of the user.</param>
|
|
/// <returns>The mail, written for the model and for the user.</returns>
|
|
public DescribedMail Describe(MailTexts texts, TimeZoneInfo timeZone)
|
|
{
|
|
var description = this.DescribeForModel(texts, timeZone);
|
|
var source = this.ToSource(texts, timeZone);
|
|
return new(description, source, this.ToPassage(source, texts, timeZone), description.ToJsonString(ToolExecutionResult.MODEL_CONTENT_OPTIONS).Length);
|
|
}
|
|
|
|
private JsonObject DescribeForModel(MailTexts texts, TimeZoneInfo timeZone)
|
|
{
|
|
var description = new JsonObject
|
|
{
|
|
["mail_id"] = this.Summary.MailId,
|
|
["received"] = MailToolResults.FormatTime(this.Summary.ReceivedAtUtc, timeZone),
|
|
["from"] = texts[this.From],
|
|
["recipients"] = new JsonArray([..this.Recipients.Select(recipient => (JsonNode?)texts[recipient])]),
|
|
["subject"] = texts[this.Subject],
|
|
["folders"] = new JsonArray([..this.Folders.Select(folder => (JsonNode?)texts[folder])]),
|
|
["is_unread"] = !this.Summary.Flags.IsSeen,
|
|
["is_flagged"] = this.Summary.Flags.IsFlagged,
|
|
["is_answered"] = this.Summary.Flags.IsAnswered,
|
|
["importance"] = MailToolArguments.ToArgumentValue(this.Summary.Importance),
|
|
};
|
|
|
|
if (this.MoreRecipients > 0)
|
|
description["more_recipients"] = this.MoreRecipients;
|
|
|
|
if (this.MoreFolders > 0)
|
|
description["more_folders"] = this.MoreFolders;
|
|
|
|
if (this.SpecialFolder is { } specialFolder)
|
|
description[MailToolArguments.SPECIAL_FOLDER_ARGUMENT] = specialFolder;
|
|
|
|
if (this.Summary.EncryptionKind is not MailEncryptionKind.NONE)
|
|
description["encryption"] = MailToolResults.GetEncryptionName(this.Summary.EncryptionKind);
|
|
|
|
if (this.Attachments.Count > 0)
|
|
description["attachments"] = new JsonArray([..this.Attachments.Select(attachment => (JsonNode?)texts[attachment])]);
|
|
|
|
if (this.MoreAttachments > 0)
|
|
description["more_attachments"] = this.MoreAttachments;
|
|
|
|
if (this.Passage is { } passage)
|
|
{
|
|
description["passage"] = texts[passage];
|
|
var attachment = this.LocatePassageInAttachment(texts);
|
|
if (attachment != MailPassageAttachment.NONE)
|
|
description["passage_location"] = attachment.Where is null ? $"attachment \"{attachment.Name}\"" : $"attachment \"{attachment.Name}\", {attachment.Where}";
|
|
}
|
|
|
|
return description;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes what the model received of this mail, for the user to read under its source.
|
|
/// </summary>
|
|
/// <param name="source">The source of this mail, see ToSource.</param>
|
|
/// <param name="texts">The filtered texts of the result.</param>
|
|
/// <param name="timeZone">The time zone of the user.</param>
|
|
/// <returns>The passage of this mail.</returns>
|
|
private SourcePassage ToPassage(Source source, MailTexts texts, TimeZoneInfo timeZone)
|
|
{
|
|
var attachment = this.LocatePassageInAttachment(texts);
|
|
return source.ToMailPassage(MailToolResults.ComposePassage(
|
|
texts[this.From],
|
|
[..this.Recipients.Select(recipient => texts[recipient])],
|
|
texts[this.Subject],
|
|
MailToolResults.FormatTime(this.Summary.ReceivedAtUtc, timeZone),
|
|
[..this.Attachments.Select(name => texts[name])],
|
|
attachment == MailPassageAttachment.NONE ? null : attachment.Label,
|
|
this.Passage is { } passage ? texts[passage] : null));
|
|
}
|
|
|
|
private Source ToSource(MailTexts texts, TimeZoneInfo timeZone)
|
|
{
|
|
var attachment = this.LocatePassageInAttachment(texts);
|
|
return MailToolResults.CreateSource(
|
|
this.Mailbox,
|
|
this.Summary.MailId,
|
|
texts[this.Subject],
|
|
texts[this.Sender],
|
|
this.Summary.ReceivedAtUtc,
|
|
timeZone,
|
|
attachment == MailPassageAttachment.NONE ? null : attachment.Label);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The attachment the passage is from, and where in it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The attachments are numbered the way the index numbers them, cf. MailSummary.AttachmentNames.
|
|
/// Where in an attachment is worded like the location of any other passage, cf.
|
|
/// IRetrievalContextExtensions.DescribeLocation. A passage of the text of the mail names
|
|
/// no attachment, and neither does one of a mail indexed before attachments were told apart.
|
|
/// </remarks>
|
|
/// <param name="texts">The filtered texts of the result.</param>
|
|
/// <returns>The filtered name of the attachment, and where in it the passage is, if known; MailPassageAttachment.NONE when the passage is from no attachment.</returns>
|
|
private MailPassageAttachment LocatePassageInAttachment(MailTexts texts)
|
|
{
|
|
if (this.PassageAttachment is not { } attachment || this.PassageLocation is not { AttachmentPosition: { } position } location)
|
|
return MailPassageAttachment.NONE;
|
|
|
|
var type = DataSourceLocalRetrievalService.GetRetrievalContentType(Path.GetExtension(this.Summary.AttachmentNames[position]));
|
|
var located = DataSourceLocalRetrievalService.LocatePassage(location, type, chunkNumber: 1);
|
|
return new(texts[attachment], IRetrievalContextExtensions.DescribeLocation(located.PageNumber, located.EndPageNumber, located.SheetName, located.StartRow, located.EndRow, located.StartLine, located.EndLine));
|
|
}
|
|
} |