using AIStudio.Tools.Databases.ProvenanceStore; using AIStudio.Tools.PluginSystem; namespace AIStudio.Tools.RAG; public static class SourcePassageExtensions { private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SourcePassageExtensions).Namespace, nameof(SourcePassageExtensions)); /// The passage. extension(SourcePassage passage) { /// /// Whether the passage comes from a transcript, e.g., of an audio or a video file. /// /// True for a transcript. public bool IsTranscript() => passage.Type is RetrievalContentType.TEXT_TRANSCRIPT; /// /// What sort of content the passage is, as the provenance store files it. /// /// /// Told by what the content is rather than by where it came from: a mail is a mail, whether a /// mailbox data source or a tool handed it over. /// /// The kind of its entry. public ProvenanceKind GetProvenanceKind() => passage.Type switch { RetrievalContentType.TEXT_EMAIL => ProvenanceKind.MAIL, RetrievalContentType.TEXT_WEBSITE => ProvenanceKind.WEB_PAGE, _ => ProvenanceKind.DATA_SOURCE_PASSAGE, }; /// /// Whether the passage holds texts which belong into the provenance store, see SourcePassage.ProvenanceId. /// /// False once the texts are stored, and for a passage without any, such as an image. public bool HoldsTextsToStore() => passage.ProvenanceId is null && (passage.Text.Length > 0 || passage.SurroundingContent.Count > 0); /// /// Names where the passage is in its document, e.g., "Page 4", "Pages 4–5", "Budget: rows 3–9", "Lines 40–85", or "Part 3". /// /// The location, or an empty string when the data source does not say. public string GetLocationLabel() => GetLocationLabel([passage]); /// /// Where the passage is in its document, as numbers only, e.g., its pages 4–5, its lines 40–85, or its part 3. /// /// /// Orders the passages of one document, in the order of the document. The passages of the /// sheets of a spreadsheet give their parts, since rows start anew in every sheet. /// /// The numbers, or null when the data source does not say. public LocationRange? GetLocationRange() => GetLocation([passage])?.Ranges[0]; } /// The source of the page or the mail, as the tool lists it. extension(Source source) { /// /// Makes the passage of a web page which a tool read for the AI, so that the user can read under /// the source of the page what the AI received from it. /// /// /// Listed under the source by its address, see GetPassagesOf, and named by its title in the /// dialog of its passages. A page says nothing about pages or lines, so its source keeps the /// title the tool gave it, see NamePassagesInTitles. /// /// The content of the page as the model received it: filtered for prompt injections, and cut where the tool cut it. /// The passage of the page. public SourcePassage ToWebPagePassage(string content) => new() { SourceUrl = source.URL, DataSourceName = source.Title, Path = source.URL, Category = RetrievalContentCategory.TEXT, Type = RetrievalContentType.TEXT_WEBSITE, Text = content, }; /// /// Makes the passage of a mail which a tool handed to the AI, so that the user can read under /// the source of the mail what the AI received of it. /// /// /// Listed and named like the passage of a web page, see ToWebPagePassage. A mail has no path: /// its address names it for AI Studio only, so there is nothing to copy or to open. /// /// What the model received of the mail, filtered for prompt injections. /// The passage of the mail. public SourcePassage ToMailPassage(string content) => new() { SourceUrl = source.URL, DataSourceName = source.Title, Category = RetrievalContentCategory.TEXT, Type = RetrievalContentType.TEXT_EMAIL, Text = content, }; } /// /// What stands between the sheets of a spreadsheet in a location, e.g., "Budget: rows 3–9 · Summary: row 2". /// private const string SHEET_SEPARATOR = " · "; /// The passages. extension(IEnumerable passages) { /// /// Names where several passages are in their document, e.g., "Pages 1, 11", "Budget: rows 3–9 · Summary: row 2", "Lines 40–85, 120" or "Parts 2, 5". /// /// The location, or an empty string when the data source does not say. public string GetLocationLabel() { var passagesOfDocument = passages.ToList(); if (passagesOfDocument.All(passage => passage.PageNumber is not > 0) && GetSheetsLabel(passagesOfDocument) is { } sheets) return sheets; return passagesOfDocument.GetLocation()?.ToString() ?? string.Empty; } /// /// Finds where several passages are in their document, in parts, so that each page can be shown /// as a link. /// /// /// A document with pages is named by its pages: they are what the user finds again in the /// document, and what a program can open it at. A table without sheets, such as a CSV file, is /// named by its rows, and a text by its lines. Rows and lines are nothing a program can open a /// document at, so they become no links. A document which says none of them is named by the /// parts it was split into for the search. Either way, the numbers follow the order of the /// document, each once: two passages on the same page name that page once. An end which is not /// after the start names no range.

/// The rows of a spreadsheet start anew in every sheet, so they need the name of their sheet /// and are named by GetLocationLabel, sheet by sheet. Here, its passages give their parts. ///
/// The location, or null when the data source says nothing about it. public PassageLocation? GetLocation() { var passagesOfDocument = passages.ToList(); var pages = passagesOfDocument .Where(passage => passage.PageNumber is > 0) .Select(passage => RangeOf(passage.PageNumber!.Value, passage.EndPageNumber)) .ToList(); if (pages.Count > 0) return CreateLocation(pages, TB("Page {0}"), TB("Pages {0}"), arePages: true); var rows = passagesOfDocument .Where(passage => passage is { StartRow: > 0, SheetName: null or "" }) .Select(passage => RangeOf(passage.StartRow!.Value, passage.EndRow)) .ToList(); if (rows.Count > 0) return CreateLocation(rows, TB("Row {0}"), TB("Rows {0}"), arePages: false); var lines = passagesOfDocument .Where(passage => passage.StartLine is > 0) .Select(passage => RangeOf(passage.StartLine!.Value, passage.EndLine)) .ToList(); if (lines.Count > 0) return CreateLocation(lines, TB("Line {0}"), TB("Lines {0}"), arePages: false); var parts = passagesOfDocument .Where(passage => passage.ChunkNumber is > 0) .Select(passage => new LocationRange(passage.ChunkNumber!.Value, passage.ChunkNumber!.Value)) .ToList(); return parts.Count > 0 ? CreateLocation(parts, TB("Part {0}"), TB("Parts {0}"), arePages: false) : null; } /// /// Finds the passages listed under a source. /// /// /// The list of sources keeps one entry per document, whichever page its link names, see /// SourceExtensions.MergeSources. The passages are matched the same way, so the entry of a /// document shows the passages of all its pages, and every passage shows up under its source. /// /// The source. /// The passages of the source, in their order; empty when it has none. public IReadOnlyList GetPassagesOf(ISource source) => passages.SortBySource().GetPassagesOf(source); /// /// Sorts passages by the source they are listed under, to find the passages of many sources. /// /// The passages by the identity of their source, see SourceExtensions.GetSourceIdentity. public ILookup SortBySource() => passages.ToLookup(passage => SourceExtensions.GetSourceIdentity(passage.SourceUrl), StringComparer.Ordinal); } /// /// Names the rows of passages from the sheets of a spreadsheet, sheet by sheet, e.g., "Budget: rows 3–9, 20 · Summary: row 2". /// /// /// The sheets follow the order of the document, which is the order of the parts the passages /// are. A passage never runs from one sheet into the next, cf. TextChunker.SplitBySheets. /// /// The passages of one document. /// The location, or null when no passage names a sheet. private static string? GetSheetsLabel(IReadOnlyList passages) { var sheets = passages .Where(passage => passage is { StartRow: > 0, SheetName.Length: > 0 }) .OrderBy(passage => passage.ChunkNumber ?? int.MaxValue) .GroupBy(passage => passage.SheetName!, StringComparer.Ordinal) .Select(sheet => GetSheetLabel(sheet.Key, sheet.Select(passage => RangeOf(passage.StartRow!.Value, passage.EndRow)))) .ToList(); return sheets.Count > 0 ? string.Join(SHEET_SEPARATOR, sheets) : null; } private static string GetSheetLabel(string sheetName, IEnumerable rows) { var orderedRows = rows .Distinct() .OrderBy(range => range.First) .ThenBy(range => range.Last) .ToList(); var format = orderedRows is [var single] && single.First == single.Last ? TB("{0}: row {1}") : TB("{0}: rows {1}"); return string.Format(format, sheetName, string.Join(PassageLocation.RANGE_SEPARATOR, orderedRows)); } /// /// The numbers from a start to an end; an end which is not after the start names no range. /// private static LocationRange RangeOf(int first, int? last) => new(first, Math.Max(first, last ?? 0)); private static PassageLocation CreateLocation(IEnumerable ranges, string oneFormat, string severalFormat, bool arePages) { var orderedRanges = ranges .Distinct() .OrderBy(range => range.First) .ThenBy(range => range.Last) .ToList(); // // The words around the numbers come from the translation, which may put them before or after // the numbers. A translation which lost its placeholder still gets the numbers, after its words: // const string PLACEHOLDER = "{0}"; var format = orderedRanges is [var single] && single.First == single.Last ? oneFormat : severalFormat; var placeholderAt = format.IndexOf(PLACEHOLDER, StringComparison.Ordinal); return placeholderAt < 0 ? new PassageLocation($"{format} ", orderedRanges, string.Empty, arePages) : new PassageLocation(format[..placeholderAt], orderedRanges, format[(placeholderAt + PLACEHOLDER.Length)..], arePages); } /// The sources of an answer. extension(IList sources) { /// /// Names in the title of each source where its passages are, e.g., "handbook.pdf (Pages 1, 11)". /// /// /// The list of sources keeps one entry per document, see SourceExtensions.MergeSources, and its /// title came from the passage found first, e.g., "handbook.pdf (Page 1)". A second passage on /// page 11 would go unmentioned, so the title is made anew from all passages of the source. A /// spreadsheet is named by its sheets and rows the same way, a text file by its lines, see /// GetLocationLabel. Passages whose data source /// says neither, e.g., an external one, are counted instead as soon as there are several of /// them. Otherwise, a source keeps the title its data source gave it, and so does every source /// without passages, such as a web page. /// /// The passages of the same answer. public void NamePassagesInTitles(IReadOnlyCollection passages) { if (passages.Count is 0) return; var passagesBySource = passages.SortBySource(); for (var index = 0; index < sources.Count; index++) { var passagesOfSource = passagesBySource.GetPassagesOf(sources[index]); if (passagesOfSource.Count is 0 || string.IsNullOrWhiteSpace(passagesOfSource[0].DataSourceName)) continue; var location = passagesOfSource.GetLocationLabel(); if (location.Length is 0 && passagesOfSource.Count > 1) location = string.Format(TB("{0} passages"), passagesOfSource.Count.CompactCount()); if (location.Length is 0) continue; sources[index] = sources[index] with { Title = $"{passagesOfSource[0].DataSourceName} ({location})" }; } } } /// The existing list of passages to merge into. extension(IList passages) { /// /// Merges added passages into an existing list of passages, leaving out those it holds already. /// /// /// A passage is known when one with the same address and the same text is, e.g., the same chunk /// found by two searches for one answer. Unlike the sources, the address counts with its page: /// the same sentence on two pages of a document is two passages. A stored passage is told /// apart by its entry, since its text lies in the provenance store. /// /// The passages to add. public void MergePassages(IEnumerable addedPassages) { var knownPassages = passages .Select(passage => (passage.SourceUrl, passage.Text, passage.ProvenanceId)) .ToHashSet(); foreach (var addedPassage in addedPassages) { if (knownPassages.Add((addedPassage.SourceUrl, addedPassage.Text, addedPassage.ProvenanceId))) passages.Add(addedPassage); } } } /// The passages of an answer, see SortBySource. extension(ILookup passagesBySource) { /// /// Finds the passages listed under a source, among passages sorted by their sources. /// /// The source. /// The passages of the source, in their order; empty when it has none. public IReadOnlyList GetPassagesOf(ISource source) => [..passagesBySource[SourceExtensions.GetSourceIdentity(source.URL)]]; } }