diff --git a/app/MindWork AI Studio/Chat/IContentExtensions.cs b/app/MindWork AI Studio/Chat/IContentExtensions.cs index 4d8f2346..d078c9d3 100644 --- a/app/MindWork AI Studio/Chat/IContentExtensions.cs +++ b/app/MindWork AI Studio/Chat/IContentExtensions.cs @@ -67,26 +67,65 @@ public static class IContentExtensions return false; } - var answer = text.Text.Trim(); - var sources = text.Sources.ToExportMarkdown(keepPageAnchors); - if (sources.Length == 0) - { - markdown = answer; - return true; - } + markdown = AppendSources(text.Text.Trim(), text.Sources.ToExportMarkdown(keepPageAnchors)); + return true; + } - if (answer.Length == 0) - { - markdown = sources; - return true; - } + /// + /// Reads one file out of this content the way it leaves AI Studio, together with the sources + /// the answer rests on. + /// + /// + /// A code block saved on its own came out of the same answer, so it rests on the same sources + /// and takes them along. How depends on the format. Markdown is what the source list is written + /// in, so a Markdown text gets it just as the entire answer does. A web page or a LaTeX document + /// gets it as a comment at its end: anything visible would have to be woven into markup the + /// model wrote. A fragment has no body to put it in, a page may hide whatever lies outside its + /// layout, and one underscore in a title is enough to stop a LaTeX run. A comment breaks + /// neither, and whoever opens the file finds it. A table gets no sources at all, since it has + /// no column a link list would fit into. + /// + /// Apart from that, the file is what the model wrote, scripts of a web page included. Saving it + /// is what the user chose to do; the chat still never renders it. + /// + /// The content the file was found in. + /// The file, as PlainFileExport.ExtractFiles read it out of this content. + /// The content of the file to write. + public static string ToExportContent(this IContent content, MessageFile file) + { + if (file.Format.IsTabular()) + return file.Content; + + var sources = content.Sources.ToExportMarkdown(file.Format.FollowsPageAnchors()); + if (file.Format is FileExportFormat.MARKDOWN) + return AppendSources(file.Content, sources); + + if (sources.Length is 0 || !file.Format.TryToComment(sources, out var comment)) + return file.Content; + + return $"{file.Content}{Environment.NewLine}{Environment.NewLine}{comment}"; + } + + /// + /// Puts the source list below a Markdown text. + /// + /// The Markdown text. + /// The source list as SourceExtensions.ToExportMarkdown writes it, or an + /// empty string when there are no sources. + /// The text followed by its sources. + private static string AppendSources(string markdown, string sources) + { + if (sources.Length == 0) + return markdown; + + if (markdown.Length == 0) + return sources; // // 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; + return $"{Markdown.CloseOpenCodeFence(markdown)}{Environment.NewLine}{Environment.NewLine}{sources}"; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs index ca42fc3d..4d261963 100644 --- a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs +++ b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs @@ -241,6 +241,42 @@ public static class FileExportFormatExtensions _ => WITHOUT_BYTE_ORDER_MARK, }; + /// + /// Wraps a text into a comment of the format: whoever opens the file in an editor reads it, + /// while a browser or a LaTeX run skips it. + /// + /// + /// A comment in HTML, and so in Markdown, ends at the first --> it holds, and a browser takes + /// --!> for the same; the rest of the text would spill onto the page from there. The title of + /// a web page may hold either, so a space goes in before the bracket, which keeps the text + /// readable and ends nothing. Every other pair of dashes stays, because a web address may carry + /// one, as in the xn-- of a domain with an umlaut. A LaTeX comment has no end to watch for: it + /// runs to the end of its line, so every line starts one. + /// + /// The format. + /// The text to put into the comment. + /// The comment, or an empty string when the format has none. + /// True, when the format knows comments. + public static bool TryToComment(this FileExportFormat format, string text, out string comment) + { + var lines = text.TrimEnd().ReplaceLineEndings("\n").Split('\n'); + switch (format) + { + case FileExportFormat.HTML or FileExportFormat.MARKDOWN: + var commentText = string.Join(Environment.NewLine, lines).Replace("-->", "-- >").Replace("--!>", "--! >"); + comment = $""; + return true; + + case FileExportFormat.LATEX: + comment = string.Join(Environment.NewLine, lines.Select(line => line.Length is 0 ? "%" : $"% {line}")); + return true; + + default: + comment = string.Empty; + return false; + } + } + /// /// Determines whether a link into a local file may name the page it points at. /// diff --git a/app/Tests/Chat/IContentExtensionsTests.cs b/app/Tests/Chat/IContentExtensionsTests.cs index 3e3fb371..aac364e6 100644 --- a/app/Tests/Chat/IContentExtensionsTests.cs +++ b/app/Tests/Chat/IContentExtensionsTests.cs @@ -162,16 +162,85 @@ public sealed class IContentExtensionsTests "|---|---|", "| Q1 | 100 |"), TOOL_SOURCE); - content.TryGetMarkdownText(out var markdown); - var tables = PlainFileExport.ExtractFiles(markdown, ','); + var tables = FilesOf(content); 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."); + Assert.That(content.ToExportContent(tables[0]), Is.EqualTo(tables[0].Content), "A data table has no column a link list would fit into."); }); } + [Test] + public void AMarkdownBlockCarriesTheSourcesVisibly() + { + var content = TextWith(Lines( + "Here are your notes:", + string.Empty, + "```markdown", + "# Notes", + string.Empty, + "The notes.", + "```"), TOOL_SOURCE); + + var exported = content.ToExportContent(FilesOf(content).Single()); + + Assert.That(TopLevelBlocksOf(exported), Is.EqualTo(new[] { "h1", "ParagraphBlock", "h1", "h2", "ListBlock" }), "The notes without the text around them, followed by the source list just as the entire answer carries it."); + } + + [Test] + public void AWebPageCarriesTheSourcesInAComment() + { + var content = TextWith(Lines( + "```html", + "", + "

Hello

", + "```"), TOOL_SOURCE); + + var file = FilesOf(content).Single(); + var exported = content.ToExportContent(file); + var appended = exported[file.Content.Length..].Trim(); + + Assert.Multiple(() => + { + Assert.That(exported, Does.StartWith(file.Content), "The page stays as the model wrote it."); + Assert.That(appended, Does.StartWith(""), "Below the page stands one comment and nothing a browser would show."); + Assert.That(appended, Does.Contain(TOOL_SOURCE.URL)); + }); + } + + [Test] + public void ALatexBlockCarriesTheSourcesInComments() + { + var content = TextWith(Lines( + "```latex", + @"\section{Results}", + "```"), TOOL_SOURCE); + + var file = FilesOf(content).Single(); + var exported = content.ToExportContent(file); + var appended = exported[file.Content.Length..].Trim(); + + Assert.Multiple(() => + { + Assert.That(exported, Does.StartWith(file.Content), "The document stays as the model wrote it."); + Assert.That(appended.Split(Environment.NewLine), Has.All.StartWith("%"), "A line LaTeX would read could stop the whole run."); + Assert.That(appended, Does.Contain(TOOL_SOURCE.URL)); + }); + } + + [TestCase("markdown")] + [TestCase("html")] + [TestCase("latex")] + public void WithoutSourcesACodeBlockStaysAsTheModelWroteIt(string language) + { + var content = TextWith(Lines($"```{language}", "The content.", "```")); + + var file = FilesOf(content).Single(); + + Assert.That(content.ToExportContent(file), Is.EqualTo(file.Content), "No comment, no heading, no empty line."); + } + /// /// A text message with the given sources hanging on it. /// @@ -184,6 +253,17 @@ public sealed class IContentExtensionsTests Sources = [..sources], }; + /// + /// Reads the files of a message the way the export menu does. + /// + /// The content to read. + /// The files the export menu offers for it. + private static IReadOnlyList FilesOf(IContent content) + { + content.TryGetMarkdownText(out var markdown); + return PlainFileExport.ExtractFiles(markdown, ','); + } + /// /// Names the blocks a Markdown text is made of, headings by their level. /// diff --git a/app/Tests/Tools/FileExportFormatTests.cs b/app/Tests/Tools/FileExportFormatTests.cs index a15f0671..6a1b0d5f 100644 --- a/app/Tests/Tools/FileExportFormatTests.cs +++ b/app/Tests/Tools/FileExportFormatTests.cs @@ -69,4 +69,54 @@ public sealed class FileExportFormatTests Assert.That(format, Is.EqualTo(FileExportFormat.NONE)); }); } + + [TestCase(FileExportFormat.HTML)] + [TestCase(FileExportFormat.MARKDOWN)] + public void AnHtmlCommentEndsWhereItShouldAndNowhereElse(FileExportFormat format) + { + var found = format.TryToComment("A page titled --> Start, and one titled --!> Next", out var comment); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(comment, Does.StartWith("", StringComparison.Ordinal), Is.EqualTo(comment.Length - 3), "Only the end of the comment may end it; the title would spill onto the page otherwise."); + Assert.That(comment, Does.Not.Contain("--!>"), "A browser ends a comment there as well."); + Assert.That(comment, Does.Contain("Start").And.Contain("Next"), "The title stays readable."); + }); + } + + [Test] + public void AnHtmlCommentKeepsTheDashesOfAnAddress() + { + FileExportFormat.HTML.TryToComment("https://xn--mnchen-3ya.de/", out var comment); + + Assert.That(comment, Does.Contain("https://xn--mnchen-3ya.de/"), "A domain with an umlaut is written with two dashes, and the link has to keep working."); + } + + [Test] + public void EveryLineOfALatexCommentIsOne() + { + var found = FileExportFormat.LATEX.TryToComment(Lines("# Sources", string.Empty, "- [1] A title with 100 % and a_b"), out var comment); + + Assert.Multiple(() => + { + Assert.That(found, Is.True); + Assert.That(comment.Split(Environment.NewLine), Is.EqualTo(new[] { "% # Sources", "%", "% - [1] A title with 100 % and a_b" }), "LaTeX has no end of a comment, only the end of a line."); + }); + } + + [TestCase(FileExportFormat.CSV)] + [TestCase(FileExportFormat.TSV)] + [TestCase(FileExportFormat.MICROSOFT_WORD)] + public void AFormatWithoutCommentsSaysSo(FileExportFormat format) + { + Assert.Multiple(() => + { + Assert.That(format.TryToComment("A text.", out var comment), Is.False); + Assert.That(comment, Is.Empty); + }); + } + + private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines); } \ No newline at end of file