diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index e627bc01..2ea0e8cd 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -5029,6 +5029,30 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78 -- Are you sure you want to delete the transcription provider '{0}'? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T789660305"] = "Are you sure you want to delete the transcription provider '{0}'?" +-- Could not open the file location. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1118835751"] = "Could not open the file location." + +-- Opens {0} +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1170655868"] = "Opens {0}" + +-- Could not open the file location: {0} +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1455637941"] = "Could not open the file location: {0}" + +-- Show this file in the file manager of your system +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1587653504"] = "Show this file in the file manager of your system" + +-- Opens {0} on page {1} +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T2402202635"] = "Opens {0} on page {1}" + +-- Unknown error +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3461425987"] = "Unknown error" + +-- Could not open the document. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3570758363"] = "Could not open the document." + +-- Could not open the document: {0} +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T945417289"] = "Could not open the document: {0}" + -- Copy {0} to the clipboard UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Copy {0} to the clipboard" @@ -12199,6 +12223,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1238078807"] = "No com -- Failed to store the API key due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Failed to store the API key due to an API issue." +-- The runtime document endpoint returned '{0}'. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1843760475"] = "The runtime document endpoint returned '{0}'." + -- The global shortcut could not be registered because of a desktop integration error. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2032590244"] = "The global shortcut could not be registered because of a desktop integration error." @@ -12226,6 +12253,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Succes -- The desktop service returned an invalid response while registering the global shortcut. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3369097283"] = "The desktop service returned an invalid response while registering the global shortcut." +-- The runtime document endpoint failed without details. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T353458993"] = "The runtime document endpoint failed without details." + -- AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3611400673"] = "AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default." @@ -12244,6 +12274,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3929880252"] = "No sav -- Failed to get the secret data due to an API issue. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T4007657575"] = "Failed to get the secret data due to an API issue." +-- The runtime document endpoint is not available. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T541638186"] = "The runtime document endpoint is not available." + -- AI Studio could not access secure storage. See the log for technical details. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T624023541"] = "AI Studio could not access secure storage. See the log for technical details." diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor index edab5ac6..51f46c48 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor @@ -223,7 +223,7 @@ } @if (textContent.Sources.Count > 0) { - + } } diff --git a/app/MindWork AI Studio/Components/SourcesList.razor b/app/MindWork AI Studio/Components/SourcesList.razor new file mode 100644 index 00000000..fcbf6eb1 --- /dev/null +++ b/app/MindWork AI Studio/Components/SourcesList.razor @@ -0,0 +1,37 @@ +@inherits MSGComponentBase + +@* The class is what the Markdown renderer wraps its own output in, so the headings and the list + keep the look they had while this list was Markdown. *@ +
+ @foreach (var group in this.groups) + { + + @group.Heading + +
    + @foreach (var entry in group.Entries) + { +
  • + @($"[{entry.Number}] ") + @if (entry.Document is { } document) + { + + + @entry.Title + + + + + + } + else + { + + @entry.Title + + } +
  • + } +
+ } +
\ No newline at end of file diff --git a/app/MindWork AI Studio/Components/SourcesList.razor.cs b/app/MindWork AI Studio/Components/SourcesList.razor.cs new file mode 100644 index 00000000..f6bfaff8 --- /dev/null +++ b/app/MindWork AI Studio/Components/SourcesList.razor.cs @@ -0,0 +1,147 @@ +using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +/// +/// Shows the sources an answer rests on, grouped and numbered the way the export is. +/// +/// +/// This list used to be Markdown, which read correctly but could not be clicked where it mattered: +/// a Markdown renderer hands every link to the browser, and the browser refuses a file address on a +/// page it loaded over http. A source of the user's own documents therefore did nothing at all. +/// Written out as components, an entry can hand its document to the runtime instead, together with +/// the page the passage was found on. +/// +public partial class SourcesList : MSGComponentBase +{ + /// + /// The sources to show. + /// + [Parameter] + public IList Sources { get; set; } = []; + + [Inject] + private RustService RustService { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; + + private readonly List groups = []; + + #region Overrides of ComponentBase + + protected override async Task OnParametersSetAsync() + { + this.RebuildGroups(); + await base.OnParametersSetAsync(); + } + + #endregion + + /// + /// Reads the sources once per render instead of once per entry and render. + /// + /// + /// Where a source points is answered by looking at its link, and while an answer streams, this + /// runs again for every chunk. The previous Markdown list was rebuilt and parsed just as often, + /// so this is the cheaper of the two, but it is still worth doing once for the whole list. + /// + private void RebuildGroups() + { + this.groups.Clear(); + foreach (var group in this.Sources.GroupSources()) + { + var entries = new List(group.Sources.Count); + foreach (var numberedSource in group.Sources) + { + var document = numberedSource.Source.TryGetDocumentLocation(out var location) ? location : (SourceDocumentLocation?)null; + entries.Add(new(numberedSource.Number, numberedSource.Source.Title, numberedSource.Source.URL, document)); + } + + this.groups.Add(new(group.Heading, entries)); + } + } + + private string GetDocumentTooltip(SourceDocumentLocation document) => document.PageNumber is > 0 + ? string.Format(T("Opens {0} on page {1}"), document.Path, document.PageNumber) + : string.Format(T("Opens {0}"), document.Path); + + /// + /// Opens a document in the program the system uses for it. + /// + /// + /// Whether the program can be sent to a page is the runtime's business, and it says afterwards + /// whether it managed to. Nothing is shown about that here: the document is open, and the title + /// of the source names the page anyway. + /// + /// The document to open, and the page to show. + private async Task OpenDocument(SourceDocumentLocation document) + { + OpenDocumentResponse response; + try + { + response = await this.RustService.TryOpenDocumentInSystemViewer(document.Path, document.PageNumber); + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Could not open a source document."); + await this.MessageBus.SendError(new(Icons.Material.Filled.Description, T("Could not open the document."))); + return; + } + + if (response.Success) + return; + + var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue; + await this.MessageBus.SendError(new(Icons.Material.Filled.Description, string.Format(T("Could not open the document: {0}"), issue))); + } + + /// + /// Opens the file browser of the system and selects the document in it. + /// + /// + /// The second way out of the list: a document which the system opens in the wrong program, or + /// which the user wants to move or send on instead of read, is reached from here without being + /// opened. This is the same way out the embeddings page offers for a file it could not read. + /// + /// The document to show. + private async Task ShowInFileManager(SourceDocumentLocation document) + { + OpenPathResponse response; + try + { + response = await this.RustService.TryOpenPathInRuntimeFileManager(document.Path); + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Could not show a source document in the file manager."); + await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, T("Could not open the file location."))); + return; + } + + if (response.Success) + return; + + var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue; + await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, string.Format(T("Could not open the file location: {0}"), issue))); + } + + /// + /// One group of the list, prepared so that the markup only has to show it. + /// + /// The heading above the group. + /// The entries of the group, in the order they are shown. + private readonly record struct SourceEntryGroup(string Heading, IReadOnlyList Entries); + + /// + /// One entry of the list, prepared so that the markup only has to show it. + /// + /// The number the source is listed under. + /// The title of the source. + /// The address of the source, which a web source is opened by. + /// The document the source names, or null when it names none. + private readonly record struct SourceEntry(int Number, string Title, string Link, SourceDocumentLocation? Document); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/NumberedSource.cs b/app/MindWork AI Studio/Tools/NumberedSource.cs new file mode 100644 index 00000000..a54abeba --- /dev/null +++ b/app/MindWork AI Studio/Tools/NumberedSource.cs @@ -0,0 +1,13 @@ +namespace AIStudio.Tools; + +/// +/// A source together with the number it is listed under. +/// +/// +/// The number runs through the whole list rather than starting over per group, because that is how +/// an answer refers to a source. It is assigned once, where the groups are formed, so the chat and +/// an exported document cannot end up numbering the same list differently. +/// +/// The number this source is listed under, counted from one. +/// The source itself. +public readonly record struct NumberedSource(int Number, Source Source); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/SourceDocumentLocation.cs b/app/MindWork AI Studio/Tools/SourceDocumentLocation.cs new file mode 100644 index 00000000..c664428a --- /dev/null +++ b/app/MindWork AI Studio/Tools/SourceDocumentLocation.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools; + +/// +/// Where a source points in the file system, and where inside the document it was found. +/// +/// The document in the file system, spelled the way this system spells a path. +/// The page the passage stands on, counted from one, or null when no page is known. +public readonly record struct SourceDocumentLocation(string Path, int? PageNumber); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/SourceExtensions.cs b/app/MindWork AI Studio/Tools/SourceExtensions.cs index 0d3ade3f..2f343104 100644 --- a/app/MindWork AI Studio/Tools/SourceExtensions.cs +++ b/app/MindWork AI Studio/Tools/SourceExtensions.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.RegularExpressions; @@ -79,6 +80,60 @@ public static partial class SourceExtensions return c is '-' or '.' or '_' or '~' or ':' or '/' or '?' or '#' or '[' or ']' or '@' or '!' or '$' or '&' or '\'' or '(' or ')' or '*' or '+' or ',' or ';' or '='; } + /// + /// Sorts a list of sources into the groups it is shown in, and numbers them. + /// + /// + /// The order of the groups and the running number are what a reader follows, and they have to + /// be the same wherever the list appears: in the chat, in an exported document, and in the + /// clipboard. This is why both the chat and the Markdown below ask here instead of sorting the + /// list themselves. + /// + /// The list of sources to sort. + /// The groups which have sources, in the order they are shown; empty when there are none. + public static IReadOnlyList GroupSources(this IList sources) + { + var llmSources = new List(); + var toolSources = new List(); + var ragSources = new List(); + foreach (var source in sources) + { + switch (source.Origin) + { + case SourceOrigin.LLM: + llmSources.Add(source); + break; + + case SourceOrigin.TOOL: + toolSources.Add(source); + break; + + case SourceOrigin.RAG: + ragSources.Add(source); + break; + } + } + + var groups = new List(3); + var sourceNum = 0; + AddGroup(groups, TB("Sources provided by the AI"), llmSources, ref sourceNum); + AddGroup(groups, TB("Sources used by tools"), toolSources, ref sourceNum); + AddGroup(groups, TB("Sources provided by the data providers"), ragSources, ref sourceNum); + return groups; + } + + private static void AddGroup(ICollection groups, string heading, IReadOnlyList sources, ref int sourceNum) + { + if (sources.Count == 0) + return; + + var numberedSources = new List(sources.Count); + foreach (var source in sources) + numberedSources.Add(new(++sourceNum, source)); + + groups.Add(new(heading, numberedSources)); + } + /// /// Converts a list of sources to a markdown-formatted string. /// @@ -87,65 +142,18 @@ public static partial class SourceExtensions public static string ToMarkdown(this IList sources) { var sb = new StringBuilder(); - var ragSources = new List(); - var toolSources = new List(); - var sourceNum = 0; - var addedLLMHeaders = false; - foreach (var source in sources) + foreach (var group in sources.GroupSources()) { - switch (source.Origin) - { - case SourceOrigin.RAG: - ragSources.Add(source); - break; - - case SourceOrigin.LLM: - if (!addedLLMHeaders) - { - sb.Append("## "); - sb.AppendLine(TB("Sources provided by the AI")); - addedLLMHeaders = true; - } - - sb.Append($"- [{++sourceNum}] "); - AppendMarkdownLink(sb, source.Title, source.URL); - sb.AppendLine(); - break; - - case SourceOrigin.TOOL: - toolSources.Add(source); - break; - } - } - - if(toolSources.Count > 0) - { - if(sb.Length > 0) + if (sb.Length > 0) sb.AppendLine(); sb.Append("## "); - sb.AppendLine(TB("Sources used by tools")); + sb.AppendLine(group.Heading); - foreach (var source in toolSources) + foreach (var numberedSource in group.Sources) { - sb.Append($"- [{++sourceNum}] "); - AppendMarkdownLink(sb, source.Title, source.URL); - sb.AppendLine(); - } - } - - if(ragSources.Count > 0) - { - if(sb.Length > 0) - sb.AppendLine(); - - sb.Append("## "); - sb.AppendLine(TB("Sources provided by the data providers")); - - foreach (var source in ragSources) - { - sb.Append($"- [{++sourceNum}] "); - AppendMarkdownLink(sb, source.Title, source.URL); + sb.Append($"- [{numberedSource.Number}] "); + AppendMarkdownLink(sb, numberedSource.Source.Title, numberedSource.Source.URL); sb.AppendLine(); } } @@ -173,6 +181,55 @@ public static partial class SourceExtensions return $"# {TB("Sources")}{Environment.NewLine}{Environment.NewLine}{sourcesMarkdown}"; } + /// + /// Reads which document a source names, and which page of it. + /// + /// + /// Only a source which names a file has such a location; a web source is opened by the browser + /// and never asks. The page rides in the fragment of the link as `page=N`, which is what the PDF + /// open parameters call for. A chat written before v26.9.1 carries `chunk=N` instead, which names + /// nothing a program could be sent to: such a source keeps its document and loses only the page. + /// + /// The source to read. + /// The document and its page, or the default when the source names no file. + /// Whether the source names a file. + public static bool TryGetDocumentLocation(this ISource source, out SourceDocumentLocation location) + { + location = default; + if (string.IsNullOrWhiteSpace(source.URL)) + return false; + + var cleanedUrl = source.URL.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty); + if (!Uri.TryCreate(cleanedUrl, UriKind.Absolute, out var absoluteUri) || !absoluteUri.IsFile) + return false; + + // + // The link was made from a path of this system, so reading it back gives that path again -- + // percent-encoded spaces and umlauts included, and with the separators this system uses. + // + var path = absoluteUri.LocalPath; + if (string.IsNullOrWhiteSpace(path)) + return false; + + location = new(path, ReadPageFromFragment(absoluteUri.Fragment)); + return true; + } + + private static int? ReadPageFromFragment(string fragment) + { + const string PAGE_PARAMETER = "page="; + foreach (var parameter in fragment.TrimStart('#').Split('&', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (!parameter.StartsWith(PAGE_PARAMETER, StringComparison.OrdinalIgnoreCase)) + continue; + + if (int.TryParse(parameter.AsSpan(PAGE_PARAMETER.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var pageNumber) && pageNumber > 0) + return pageNumber; + } + + return null; + } + /// /// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs. /// diff --git a/app/MindWork AI Studio/Tools/SourceGroup.cs b/app/MindWork AI Studio/Tools/SourceGroup.cs new file mode 100644 index 00000000..c85419aa --- /dev/null +++ b/app/MindWork AI Studio/Tools/SourceGroup.cs @@ -0,0 +1,8 @@ +namespace AIStudio.Tools; + +/// +/// One group of a source list: a heading and the sources below it. +/// +/// The heading above the group. +/// The sources of the group, in the order they are shown. +public readonly record struct SourceGroup(string Heading, IReadOnlyList Sources); \ No newline at end of file diff --git a/app/Tests/Tools/SourceExtensionsTests.cs b/app/Tests/Tools/SourceExtensionsTests.cs index af1ebf56..22cc1e29 100644 --- a/app/Tests/Tools/SourceExtensionsTests.cs +++ b/app/Tests/Tools/SourceExtensionsTests.cs @@ -85,6 +85,134 @@ public sealed class SourceExtensionsTests }); } + [Test] + public void TheGroupingIsWhatTheChatAndTheExportBothRead() + { + // Mixed on purpose, and with two sources of one origin, so neither the order of the groups + // nor the order inside a group can come from the order of the input: + IList sources = + [ + new("Handbook", "https://example.org/handbook", SourceOrigin.RAG), + new("Search result", "https://example.org/search", SourceOrigin.TOOL), + new("Cited by the model", "https://example.org/cited", SourceOrigin.LLM), + new("Second handbook", "https://example.org/handbook-2", SourceOrigin.RAG), + ]; + + var listed = sources.GroupSources().SelectMany(group => group.Sources).ToList(); + + Assert.Multiple(() => + { + Assert.That(sources.GroupSources(), Has.Count.EqualTo(3), "Each of the three origins has a source, so each of them is a group."); + Assert.That(listed.Select(numbered => numbered.Source.Title), Is.EqualTo(new[] { "Cited by the model", "Search result", "Handbook", "Second handbook" }), "What the AI cited comes first, then what the tools read, then what the data providers gave."); + Assert.That(listed.Select(numbered => numbered.Number), Is.EqualTo(new[] { 1, 2, 3, 4 }), "The number runs through the whole list instead of starting over per group."); + }); + } + + [Test] + public void AnOriginWithoutSourcesIsNoGroup() + { + IList sources = [new("Search result", "https://example.org/search", SourceOrigin.TOOL)]; + + Assert.Multiple(() => + { + Assert.That(sources.GroupSources().Select(group => group.Sources.Count), Is.EqualTo(new[] { 1 }), "An answer which only used a tool gets one group, not three with two of them empty."); + Assert.That(new List().GroupSources(), Is.Empty, "An answer nobody had to look up gets no group at all."); + }); + } + + [Test] + public void TheMarkdownListsExactlyWhatTheGroupingSaysItShould() + { + IList sources = + [ + new("Handbook (Page 12)", "file:///Users/someone/handbook.pdf#page=12", SourceOrigin.RAG), + new("Cited by the model", "https://example.org/cited", SourceOrigin.LLM), + ]; + + var entries = EntriesOf(sources.ToMarkdown()); + var listed = sources.GroupSources().SelectMany(group => group.Sources).ToList(); + + Assert.That(entries, Has.Count.EqualTo(listed.Count), "Every source the grouping lists is written out, and nothing else is."); + for (var index = 0; index < entries.Count; index++) + Assert.That(entries[index], Does.StartWith($"- [{listed[index].Number}] ").And.Contains(listed[index].Source.Title), "The Markdown and the chat read the same grouping, so a source cannot be numbered one way here and another way there."); + } + + [Test] + public void AKnownPageRidesInTheLinkOfASource() + { + var location = LocationOf("file:///Users/someone/My%20Documents/Gr%C3%B6%C3%9Fere%20%C3%9Cbersicht.pdf#page=12"); + + Assert.Multiple(() => + { + Assert.That(location.Path, Does.EndWith("Größere Übersicht.pdf").And.Contains("My Documents"), "The percent-encoding of the link is undone, so the program is handed the name the file really has."); + Assert.That(location.PageNumber, Is.EqualTo(12), "This is the page the passage was found on, and the page the document is opened at."); + }); + } + + [Test] + public void APathOfAWindowsMachineComesBackAsOne() + { + var location = LocationOf("file:///C:/Users/someone/Documents/handbook.pdf#page=3"); + + Assert.Multiple(() => + { + Assert.That(location.Path, Is.EqualTo(@"C:\Users\someone\Documents\handbook.pdf"), "A drive letter and backslashes are what a program on Windows is handed -- and what the link was made from there."); + Assert.That(location.PageNumber, Is.EqualTo(3)); + }); + } + + [Test] + public void AChatFromBeforeThisReleaseKeepsItsDocumentAndLosesOnlyItsPage() + { + var location = LocationOf("file:///Users/someone/handbook.pdf#chunk=3"); + + Assert.Multiple(() => + { + Assert.That(location.Path, Does.EndWith("handbook.pdf"), "Such a source still names its document, so the click still opens it."); + Assert.That(location.PageNumber, Is.Null, "A chunk is not a page: no program can be sent to one, so the document opens on its first page."); + }); + } + + [Test] + public void ALinkWithoutAFragmentNamesNoPage() + { + Assert.That(LocationOf("file:///Users/someone/handbook.pdf").PageNumber, Is.Null); + } + + [Test] + public void APageWhichIsNoPageIsReadAsNone() + { + Assert.Multiple(() => + { + Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=0").PageNumber, Is.Null, "Pages are counted from one, so a zero is not a page."); + Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=-2").PageNumber, Is.Null); + Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=twelve").PageNumber, Is.Null); + Assert.That(LocationOf("file:///Users/someone/handbook.pdf#chunk=3&page=12").PageNumber, Is.EqualTo(12), "A link which already carried a fragment gets the page appended with an ampersand, and it is found there too."); + }); + } + + [Test] + public void AWebSourceNamesNoDocumentAtAll() + { + // The fragment reads like a page on purpose: what decides is the scheme, not the fragment. + ISource source = new Source("Article", "https://example.org/article#page=12", SourceOrigin.LLM); + + Assert.That(source.TryGetDocumentLocation(out _), Is.False, "A web source is opened by the browser and has no path to hand to a program."); + } + + /// + /// Reads where the link of a source points, and fails the test when it points nowhere. + /// + /// The link of the source. + /// The document and the page the link names. + private static SourceDocumentLocation LocationOf(string url) + { + ISource source = new Source("Handbook", url, SourceOrigin.RAG); + + Assert.That(source.TryGetDocumentLocation(out var location), Is.True, "This link names a file, so a location is what it has."); + return location; + } + /// /// Reads the entries of a source list, without the headings above them. ///