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; /// /// A mail found, with its texts waiting to be filtered. /// /// /// /// Whether the mail lies among the sent mails or the drafts is AI Studio's own value, so it needs no filtering. /// /// /// 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. /// /// internal sealed record PendingMail(DataSourceMailbox Mailbox, MailSummary Summary, int Subject, int From, int Sender, IReadOnlyList Recipients, int MoreRecipients, IReadOnlyList Folders, int MoreFolders, string? SpecialFolder, IReadOnlyList Attachments, int MoreAttachments, int? Passage, ChunkLocation? PassageLocation, int? PassageAttachment) { /// /// How much of the passage which matched best a result shows. /// /// /// 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. /// private const int MAX_PASSAGE_CHARACTERS = 1_500; /// /// How much of a subject a result shows. Subjects are short, unless somebody wrote a letter into one. /// private const int MAX_SUBJECT_CHARACTERS = 300; /// /// How many recipients of a mail a result names. A mail to a large list would otherwise fill the result. /// private const int MAX_LISTED_RECIPIENTS = 5; /// /// 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. /// private const int MAX_LISTED_FOLDERS = 10; /// /// How many attachments of a mail a result names, as many as the index lists in the text of a mail, cf. MailTextBuilder. /// private const int MAX_LISTED_ATTACHMENTS = 20; /// /// How much of an address, of the name of a sender, of a folder, or of the name of an attachment a result shows. /// /// /// As much as of a subject. Nothing keeps a sender from writing a display name or a file name of any length. /// private const int MAX_NAME_CHARACTERS = MAX_SUBJECT_CHARACTERS; /// /// Registers everything a result shows of a mail with the texts to filter. /// /// /// 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. /// /// The mail found. /// The mailbox it lies in. /// The folders of the mailbox, to tell the sent mails and the drafts. /// The texts of the result, to be filtered together. /// The mail, waiting for its texts to be filtered. public static PendingMail Register(MailSearchHit hit, DataSourceMailbox mailbox, IReadOnlyList 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); } /// /// Writes what the model and the user get of this mail, once its texts went through the filter. /// /// /// 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. /// /// The filtered texts of the result. /// The time zone of the user. /// The mail, written for the model and for the user. 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; } /// /// Writes what the model received of this mail, for the user to read under its source. /// /// The source of this mail, see ToSource. /// The filtered texts of the result. /// The time zone of the user. /// The passage of this mail. 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); } /// /// The attachment the passage is from, and where in it. /// /// /// 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. /// /// The filtered texts of the result. /// The filtered name of the attachment, and where in it the passage is, if known; MailPassageAttachment.NONE when the passage is from no attachment. 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)); } }