From 65a1683b9523fbc86486e4aa7a8e671013545ad8 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 13 Sep 2026 19:37:15 +0200 Subject: [PATCH] Fixed exports and the clipboard losing the sources of an answer (#966) --- .../Assistants/I18N/allTexts.lua | 3 + .../Chat/ContentBlockComponent.razor.cs | 2 +- .../Chat/IContentExtensions.cs | 48 +++- .../MudCopyClipboardButton.razor.cs | 12 +- .../plugin.lua | 3 + .../plugin.lua | 3 + app/MindWork AI Studio/Tools/Markdown.cs | 25 +++ app/MindWork AI Studio/Tools/PandocExport.cs | 2 +- .../Tools/SourceExtensions.cs | 20 ++ .../wwwroot/changelog/v26.9.1.md | 2 + app/Tests/Chat/IContentExtensionsTests.cs | 205 ++++++++++++++++++ app/Tests/Tools/MarkdownTests.cs | 66 ++++++ app/Tests/Tools/SourceExtensionsTests.cs | 97 +++++++++ 13 files changed, 481 insertions(+), 7 deletions(-) create mode 100644 app/Tests/Chat/IContentExtensionsTests.cs create mode 100644 app/Tests/Tools/MarkdownTests.cs create mode 100644 app/Tests/Tools/SourceExtensionsTests.cs diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 849acf37..93267adf 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -12241,6 +12241,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Fail -- Failed to install update automatically. Please try again manually. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T3709709946"] = "Failed to install update automatically. Please try again manually." +-- Sources +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T2730980305"] = "Sources" + -- Sources provided by the data providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Sources provided by the data providers" diff --git a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs index b2ec2bab..9ee23715 100644 --- a/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs +++ b/app/MindWork AI Studio/Chat/ContentBlockComponent.razor.cs @@ -732,7 +732,7 @@ public partial class ContentBlockComponent : MSGComponentBase // if (format.UsesPandoc()) await PandocExport.ToDocument(this.RustService, this.PandocAvailability, this.EffectiveExportTitle, format, this.Content); - else if (this.Content.TryGetMarkdownText(out var markdown)) + else if (this.Content.TryGetExportMarkdown(out var markdown)) await PlainFileExport.ToFile(this.RustService, this.EffectiveExportTitle, format, markdown); } catch (ArgumentOutOfRangeException e) diff --git a/app/MindWork AI Studio/Chat/IContentExtensions.cs b/app/MindWork AI Studio/Chat/IContentExtensions.cs index 4b86cd72..431b70b8 100644 --- a/app/MindWork AI Studio/Chat/IContentExtensions.cs +++ b/app/MindWork AI Studio/Chat/IContentExtensions.cs @@ -24,7 +24,9 @@ public static class IContentExtensions /// /// Only text content carries Markdown. Everything else, an image for example, has no text /// representation at all, which is why this reports failure instead of returning a placeholder: - /// a caller which writes files must not put an excuse into the file it writes. + /// a caller which writes files must not put an excuse into the file it writes. This is the text + /// the model wrote and nothing else: whoever reads a table out of a message wants exactly that, + /// while whoever writes a file wants the sources along with it and asks for the export reading. /// /// The content to read. /// The Markdown text, or an empty string when there is none. @@ -40,4 +42,48 @@ public static class IContentExtensions markdown = string.Empty; return false; } + + /// + /// Reads this content the way it leaves AI Studio, as a file or through the clipboard. + /// + /// + /// What the user sees is the answer together with the sources AI Studio collected for it, and + /// that is what a document has to hold as well: an answer built on a web page a tool read, or on + /// a document of the user, is worth little when the reader cannot tell which one it was. Those + /// sources are not part of the text the model wrote, they hang on the content, which is why + /// every path out of the app asks for this and not for the text alone. + /// + /// The content to read. + /// The Markdown text including its sources, or an empty string when there is none. + /// True, when this content carries Markdown text. + public static bool TryGetExportMarkdown(this IContent content, out string markdown) + { + if (content is not ContentText text) + { + markdown = string.Empty; + return false; + } + + var answer = text.Text.Trim(); + var sources = text.Sources.ToExportMarkdown(); + if (sources.Length == 0) + { + markdown = answer; + return true; + } + + if (answer.Length == 0) + { + markdown = sources; + return true; + } + + // + // The blank line is not cosmetic: it ends a paragraph, a list, a table, or a block quote, so + // that the heading of the source list stands on its own instead of being pulled into the + // last block of the answer. + // + markdown = $"{Markdown.CloseOpenCodeFence(answer)}{Environment.NewLine}{Environment.NewLine}{sources}"; + return true; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/MudCopyClipboardButton.razor.cs b/app/MindWork AI Studio/Components/MudCopyClipboardButton.razor.cs index 86c067ba..689cddc4 100644 --- a/app/MindWork AI Studio/Components/MudCopyClipboardButton.razor.cs +++ b/app/MindWork AI Studio/Components/MudCopyClipboardButton.razor.cs @@ -61,16 +61,20 @@ public partial class MudCopyClipboardButton : ComponentBase /// /// Copy this block's content to the clipboard. /// + /// + /// The user copies what the card shows, and the card shows the answer together with the sources + /// AI Studio collected for it. Pasting the answer into a mail without them would leave the + /// reader with claims nobody is able to check. + /// private async Task CopyToClipboard(IContent? contentToCopy) { if (contentToCopy is null) return; - + switch (this.Type) { - case ContentType.TEXT: - var textContent = (ContentText) contentToCopy; - await this.RustService.CopyText2Clipboard(textContent.Text); + case ContentType.TEXT when contentToCopy.TryGetExportMarkdown(out var markdown): + await this.RustService.CopyText2Clipboard(markdown); break; default: diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 80ec3969..fd4f3497 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -12243,6 +12243,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Die -- Failed to install update automatically. Please try again manually. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T3709709946"] = "Fehler bei der automatischen Installation des Updates. Bitte versuchen Sie es manuell erneut." +-- Sources +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T2730980305"] = "Quellen" + -- Sources provided by the data providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Von den Datenanbietern bereitgestellte Quellen" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 640e220c..2b02dbe2 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -12243,6 +12243,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T1064148123"] = "Fail -- Failed to install update automatically. Please try again manually. UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::UPDATESERVICE::T3709709946"] = "Failed to install update automatically. Please try again manually." +-- Sources +UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T2730980305"] = "Sources" + -- Sources provided by the data providers UI_TEXT_CONTENT["AISTUDIO::TOOLS::SOURCEEXTENSIONS::T4174900468"] = "Sources provided by the data providers" diff --git a/app/MindWork AI Studio/Tools/Markdown.cs b/app/MindWork AI Studio/Tools/Markdown.cs index c523795b..d43e2979 100644 --- a/app/MindWork AI Studio/Tools/Markdown.cs +++ b/app/MindWork AI Studio/Tools/Markdown.cs @@ -1,4 +1,5 @@ using Markdig; +using Markdig.Syntax; using System.Text; namespace AIStudio.Tools; @@ -58,6 +59,30 @@ public static class Markdown return escaped.ToString(); } + /// Closes a code fence which the text opened but never closed. + /// + /// An unclosed fence runs to the end of the document, so anything appended after it would be + /// read as code instead of as Markdown. The chat never shows this, because it renders the answer + /// and what belongs below it separately. A document is one text, and there an answer which ends + /// in an open fence would swallow whatever follows it. + /// + /// The Markdown text to inspect. + /// The text with its open fence closed, or the text itself when no fence is open. + public static string CloseOpenCodeFence(string markdownText) + { + if (string.IsNullOrWhiteSpace(markdownText)) + return markdownText; + + var document = Markdig.Markdown.Parse(markdownText, SAFE_MARKDOWN_PIPELINE); + + // Only the last fence of a text can be an open one: an open fence takes everything + // after it with it, so no other block is able to follow it. + if (document.Descendants().LastOrDefault() is not { ClosingFencedCharCount: 0 } openFence) + return markdownText; + + return $"{markdownText}{Environment.NewLine}{new string(openFence.FencedChar, openFence.OpeningFencedCharCount)}"; + } + public static string RemoveSharedIndentation(string value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/app/MindWork AI Studio/Tools/PandocExport.cs b/app/MindWork AI Studio/Tools/PandocExport.cs index db804017..c7ce9738 100644 --- a/app/MindWork AI Studio/Tools/PandocExport.cs +++ b/app/MindWork AI Studio/Tools/PandocExport.cs @@ -118,7 +118,7 @@ public static class PandocExport // We read the text before we ask for a path: when there is nothing to convert, the user // should learn that right away instead of picking a file first and getting an error afterwards. // - if (!markdownContent.TryGetMarkdownText(out var markdownText)) + if (!markdownContent.TryGetExportMarkdown(out var markdownText)) { LOGGER.LogWarning("Cannot export the content as {ExportFormat}, because it carries no text.", format); await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Only text messages can be exported."))); diff --git a/app/MindWork AI Studio/Tools/SourceExtensions.cs b/app/MindWork AI Studio/Tools/SourceExtensions.cs index d8c5e1af..0d3ade3f 100644 --- a/app/MindWork AI Studio/Tools/SourceExtensions.cs +++ b/app/MindWork AI Studio/Tools/SourceExtensions.cs @@ -153,6 +153,26 @@ public static partial class SourceExtensions return sb.ToString(); } + /// + /// Converts a list of sources to a markdown-formatted string, headed by a title of its own. + /// + /// + /// The chat shows the sources in a box below the answer, so the reader sees where the one ends + /// and the others begin. An exported document is one text: without a heading of its own, the + /// source list would read like one more section the model wrote. This is why the export asks + /// for this and the chat does not. + /// + /// The list of sources to convert. + /// A markdown-formatted string representing the sources, or an empty string when there are none. + public static string ToExportMarkdown(this IList sources) + { + var sourcesMarkdown = sources.ToMarkdown(); + if (string.IsNullOrWhiteSpace(sourcesMarkdown)) + return string.Empty; + + return $"# {TB("Sources")}{Environment.NewLine}{Environment.NewLine}{sourcesMarkdown}"; + } + /// /// 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/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md index 81ec9511..1594aea7 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -36,4 +36,6 @@ - Fixed the web address staying in the field when you reset an assistant that loads content from a web page. - Fixed the web address being gone when you leave such an assistant and come back to it later. - Fixed the Visual Briefing Assistant (in preview) not scrolling, which put everything below the window edge out of reach and made the assistant unusable. The briefing preview is now shown at its intended size inside its frame, and switching between the desktop, tablet, and mobile view changes its width as it should. +- Fixed exported answers losing their sources. When an answer is based on web pages a tool read or on documents of your own, the exported file now lists those sources, in every format AI Studio writes. +- Fixed the copy button leaving the sources behind. Copy an answer, and its sources come along. - Upgraded the Visual Briefing Assistant (in preview) from the prototype to the beta state. The assistant is now completely implemented and is undergoing a deeper testing phase in preparation for release. To try it, open the app settings, allow preview features down to beta, and then enable the Visual Briefing Assistant there. diff --git a/app/Tests/Chat/IContentExtensionsTests.cs b/app/Tests/Chat/IContentExtensionsTests.cs new file mode 100644 index 00000000..873ecf53 --- /dev/null +++ b/app/Tests/Chat/IContentExtensionsTests.cs @@ -0,0 +1,205 @@ +using AIStudio.Chat; +using AIStudio.Tools; + +using Markdig.Syntax; + +namespace AIStudio.Tests.Chat; + +/// +/// Checks that an answer leaves AI Studio together with the sources it rests on. +/// +/// +/// The sources under an answer come from AI Studio, not from the model, so they are not part of the +/// text a file writer reads. With RAG and web search in v26.9.1 that is most of what makes an answer +/// checkable: a document which says a page was read, without saying which one, is worth little to +/// whoever receives it. The chat renders the answer and the sources as two texts, which hides every +/// way the one can run into the other -- an open code fence above all. A document has no such seam. +/// +[TestFixture] +public sealed class IContentExtensionsTests +{ + private static readonly Source TOOL_SOURCE = new("Search result", "https://example.org/search", SourceOrigin.TOOL); + + [Test] + public void TheSourcesFollowTheAnswer() + { + var content = TextWith("The answer of the model ends here.", TOOL_SOURCE); + + var found = content.TryGetExportMarkdown(out var markdown); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(markdown, Does.EndWith(content.Sources.ToExportMarkdown()), "What the chat shows below the answer is what the file holds below it."); + Assert.That(TopLevelBlocksOf(markdown), Is.EqualTo(new[] { "ParagraphBlock", "h1", "h2", "ListBlock" }), "The answer stays a paragraph of its own; the source list starts under its own heading."); + }); + } + + [Test] + public void AnAnswerEndingInATableKeepsIt() + { + var content = TextWith(Lines( + "Here are the numbers:", + string.Empty, + "| Quarter | Revenue |", + "|---|---|", + "| Q1 | 100 |"), TOOL_SOURCE); + + var found = content.TryGetExportMarkdown(out var markdown); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(TopLevelBlocksOf(markdown), Is.EqualTo(new[] { "ParagraphBlock", "Table", "h1", "h2", "ListBlock" }), "The table ends where it ended; the headings below it are not two more rows."); + }); + } + + [Test] + public void AnAnswerEndingInAListKeepsIt() + { + var content = TextWith(Lines( + "Three points:", + string.Empty, + "- one", + "- two", + "- three"), TOOL_SOURCE); + + var found = content.TryGetExportMarkdown(out var markdown); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(TopLevelBlocksOf(markdown), Is.EqualTo(new[] { "ParagraphBlock", "ListBlock", "h1", "h2", "ListBlock" }), "Two lists, not one: the sources do not become the fourth point of the answer."); + }); + } + + [Test] + public void AnOpenCodeFenceDoesNotSwallowTheSources() + { + // Either the model forgot the closing fence, or the answer was cut short. Both happen, and + // in a document both would turn everything below into code: + var content = TextWith(Lines( + "Here is the code:", + string.Empty, + "```csharp", + "var answer = 42;"), TOOL_SOURCE); + + var found = content.TryGetExportMarkdown(out var markdown); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(TopLevelBlocksOf(markdown), Is.EqualTo(new[] { "ParagraphBlock", "FencedCodeBlock", "h1", "h2", "ListBlock" }), "The code block is closed for the model, so the sources stand below it instead of inside it."); + }); + } + + [Test] + public void WithoutSourcesNothingIsAdded() + { + const string ANSWER = " An answer nobody had to look anything up for. "; + var content = TextWith(ANSWER); + + var found = content.TryGetExportMarkdown(out var markdown); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(markdown, Is.EqualTo(ANSWER.Trim()), "The everyday case: no heading, no empty line, nothing anybody has to explain."); + }); + } + + [Test] + public void WithoutAnAnswerTheSourcesStandAlone() + { + var content = TextWith(string.Empty, TOOL_SOURCE); + + var found = content.TryGetExportMarkdown(out var markdown); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(markdown, Is.EqualTo(content.Sources.ToExportMarkdown()), "Nothing above the heading means no empty line above it either."); + }); + } + + [Test] + public void APictureHasNothingToExport() + { + IContent picture = new ContentImage + { + SourceType = ContentImageSource.URL, + Source = "https://example.org/picture.png", + Sources = [TOOL_SOURCE], + }; + + Assert.Multiple(() => + { + Assert.That(picture.TryGetExportMarkdown(out var markdown), Is.False, "There is no text document for a picture, so the caller hears no and says so."); + Assert.That(markdown, Is.Empty, "A file writer must not put an excuse into the file it writes."); + }); + } + + [Test] + public void TheTableReadingStaysTheTextOfTheModel() + { + const string ANSWER = " An answer with a source hanging on it. "; + var content = TextWith(ANSWER, TOOL_SOURCE); + + var found = content.TryGetMarkdownText(out var markdown); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(markdown, Is.EqualTo(ANSWER), "Neither trimmed nor extended: whoever reads a table out of a message wants what the model wrote and nothing else."); + }); + } + + [Test] + public void ATableExportCarriesNoSources() + { + var content = TextWith(Lines( + "| Quarter | Revenue |", + "|---|---|", + "| Q1 | 100 |"), TOOL_SOURCE); + + content.TryGetMarkdownText(out var markdown); + var tables = PlainFileExport.ExtractTables(markdown, ','); + + Assert.Multiple(() => + { + Assert.That(tables, Has.Count.EqualTo(1), "One table in the message, one table offered for it."); + Assert.That(tables[0].Content, Does.Not.Contain("example.org"), "A data table has no column a link list would fit into."); + }); + } + + /// + /// A text message with the given sources hanging on it. + /// + /// The text the model wrote. + /// The sources AI Studio collected for it. + /// The content. + private static ContentText TextWith(string text, params Source[] sources) => new() + { + Text = text, + Sources = [..sources], + }; + + /// + /// Names the blocks a Markdown text is made of, headings by their level. + /// + /// + /// Only the blocks of the document itself, not the ones nested in them: whether the source list + /// stands on the document or inside the last block of the answer is the whole question here. + /// Markdig hangs a group for link reference definitions at the end of every document, which + /// carries no text and is left out. + /// + /// The Markdown text to read. + /// The names, in the order the blocks stand in. + private static IReadOnlyList TopLevelBlocksOf(string markdown) => Markdig.Markdown + .Parse(markdown, Markdown.SAFE_MARKDOWN_PIPELINE) + .Where(block => block is not LinkReferenceDefinitionGroup) + .Select(block => block is HeadingBlock heading ? $"h{heading.Level}" : block.GetType().Name) + .ToList(); + + private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines); +} \ No newline at end of file diff --git a/app/Tests/Tools/MarkdownTests.cs b/app/Tests/Tools/MarkdownTests.cs new file mode 100644 index 00000000..9344de8a --- /dev/null +++ b/app/Tests/Tools/MarkdownTests.cs @@ -0,0 +1,66 @@ +using AIStudio.Tools; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks that an answer which opens a code fence without closing it ends where it ends. +/// +/// +/// A fence without its counterpart runs to the end of the document, so whatever is appended below an +/// answer is read as code instead of as Markdown. The chat never shows this, because it renders the +/// answer and the sources below it as two texts. A document is one text, and there an answer which +/// ends in an open fence takes the source list with it into a grey box. +/// +[TestFixture] +public sealed class MarkdownTests +{ + [Test] + public void AnOpenFenceGetsItsCounterpart() + { + var answer = Lines("Here is the code:", string.Empty, "```csharp", "var answer = 42;"); + + Assert.That(Markdown.CloseOpenCodeFence(answer), Is.EqualTo(Lines(answer, "```")), "The fence is closed with the same three backticks which opened it."); + } + + [Test] + public void ALongerFenceIsClosedAtItsOwnLength() + { + // Four backticks are what a model writes when the block itself holds Markdown with code in + // it. The three backticks inside are content then, not the end of the block: + var answer = Lines("````markdown", "```csharp", "var answer = 42;", "```"); + + Assert.That(Markdown.CloseOpenCodeFence(answer), Is.EqualTo(Lines(answer, "````")), "Only a fence of at least the opening length closes the block."); + } + + [Test] + public void ATildeFenceIsClosedWithTildes() + { + var answer = Lines("~~~", "var answer = 42;"); + + Assert.That(Markdown.CloseOpenCodeFence(answer), Is.EqualTo(Lines(answer, "~~~")), "A block opened with tildes cannot be closed with backticks."); + } + + [Test] + public void AClosedFenceIsLeftAlone() + { + var answer = Lines("Here is the code:", string.Empty, "```csharp", "var answer = 42;", "```"); + + Assert.That(Markdown.CloseOpenCodeFence(answer), Is.EqualTo(answer), "The usual case: the model closed its block itself."); + } + + [Test] + public void TextWithoutAnyFenceIsLeftAlone() + { + const string ANSWER = "Nothing in this answer opens a code block."; + + Assert.That(Markdown.CloseOpenCodeFence(ANSWER), Is.EqualTo(ANSWER)); + } + + [Test] + public void AnEmptyTextIsLeftAlone() + { + Assert.That(Markdown.CloseOpenCodeFence(string.Empty), Is.Empty); + } + + private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines); +} \ No newline at end of file diff --git a/app/Tests/Tools/SourceExtensionsTests.cs b/app/Tests/Tools/SourceExtensionsTests.cs new file mode 100644 index 00000000..af1ebf56 --- /dev/null +++ b/app/Tests/Tools/SourceExtensionsTests.cs @@ -0,0 +1,97 @@ +using AIStudio.Tools; + +using Markdig.Syntax; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks how the list of sources reads once it is written out as Markdown. +/// +/// +/// The sources are the one part of an answer which AI Studio writes itself, and since v26.9.1 they +/// no longer stay in the chat: they travel into every exported document and into the clipboard. A +/// number which starts over per group, or a title which breaks out of the link it sits in, is then +/// in a file somebody sends on. Nothing here asserts on the wording of a heading: I18N is +/// process-wide state without a reset, so an Init somewhere else would decide whether these pass. +/// +[TestFixture] +public sealed class SourceExtensionsTests +{ + [Test] + public void TheGroupsKeepTheirOrderAndTheNumbersRunThrough() + { + // Mixed on purpose, so that the order of the output cannot 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), + ]; + + Assert.That(EntriesOf(sources.ToMarkdown()), Is.EqualTo(new[] + { + "- [1] [Cited by the model]()", + "- [2] [Search result]()", + "- [3] [Handbook]()", + }), "What the AI cited comes first, then what the tools read, then what the data providers gave -- and a reader can follow the numbers straight down the list."); + } + + [Test] + public void ATitleCannotBreakOutOfItsLink() + { + IList sources = [new("A [strange] title\\with a break\nin it", "https://example.org/", SourceOrigin.TOOL)]; + + Assert.That(EntriesOf(sources.ToMarkdown()).Single(), Is.EqualTo(@"- [1] [A \[strange\] title\\with a break in it]()"), "Brackets and backslashes are escaped, and the line break becomes a space so the entry stays one line."); + } + + [Test] + public void ALocalPathKeepsWorkingAsALink() + { + // This is what a RAG hit on a file of the user looks like, and spaces in file names are the + // rule rather than the exception: + IList sources = [new("Handbook (page 12)", "file:///Users/someone/My Documents/handbook.pdf", SourceOrigin.RAG)]; + + Assert.That(EntriesOf(sources.ToMarkdown()).Single(), Is.EqualTo("- [1] [Handbook (page 12)]()"), "A space would end the link destination, so it is escaped."); + } + + [Test] + public void NoSourcesMeanNoText() + { + IList sources = []; + + Assert.Multiple(() => + { + Assert.That(sources.ToMarkdown(), Is.Empty); + Assert.That(sources.ToExportMarkdown(), Is.Empty, "An answer nobody had to look up gets no heading over an empty list."); + }); + } + + [Test] + public void TheExportPutsOneHeadingOfItsOwnAboveTheGroups() + { + IList sources = + [ + new("Search result", "https://example.org/search", SourceOrigin.TOOL), + new("Handbook", "https://example.org/handbook", SourceOrigin.RAG), + ]; + + var exported = sources.ToExportMarkdown(); + var document = Markdig.Markdown.Parse(exported, Markdown.SAFE_MARKDOWN_PIPELINE); + + Assert.Multiple(() => + { + Assert.That(document.OfType().Select(heading => heading.Level), Is.EqualTo(new[] { 1, 2, 2 }), "One heading of its own stands above the two groups the chat already shows."); + Assert.That(exported, Does.EndWith(sources.ToMarkdown()), "Below that heading, the export is what the chat shows, unchanged."); + }); + } + + /// + /// Reads the entries of a source list, without the headings above them. + /// + /// The Markdown of the sources. + /// The entries, in the order they stand in. + private static IReadOnlyList EntriesOf(string markdown) => markdown + .Split(Environment.NewLine, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Where(line => line.StartsWith("- [", StringComparison.Ordinal)) + .ToList(); +} \ No newline at end of file