diff --git a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs index 5a22f93b..ca42fc3d 100644 --- a/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs +++ b/app/MindWork AI Studio/Tools/FileExportFormatExtensions.cs @@ -133,6 +133,13 @@ public static class FileExportFormatExtensions return format is not FileExportFormat.NONE; } + /// + /// Determines whether the format holds a table rather than a text. + /// + /// The format. + /// True for the formats a spreadsheet opens. + public static bool IsTabular(this FileExportFormat format) => format is FileExportFormat.CSV or FileExportFormat.TSV; + /// /// Returns the file name the save dialog starts with. /// diff --git a/app/MindWork AI Studio/Tools/MessageFile.cs b/app/MindWork AI Studio/Tools/MessageFile.cs index 15668106..28151d7a 100644 --- a/app/MindWork AI Studio/Tools/MessageFile.cs +++ b/app/MindWork AI Studio/Tools/MessageFile.cs @@ -1,12 +1,14 @@ namespace AIStudio.Tools; /// -/// A file found in a message, ready to be written: a table the model wrote. +/// A file found in a message, ready to be written: a table the model wrote, or a code block the +/// model marked as a format we write. /// -/// Which table of the message this is, counting from one. The same table -/// appears once per format we offer for it, so this is what tells two tables apart even when they -/// carry the same heading. -/// What the table is about, taken from its first column heading. +/// Which table or which code block of the message this is, counting from one +/// within its kind; the format tells the two kinds apart, see FileExportFormatExtensions.IsTabular. +/// This is what tells two files of one kind apart even when they carry the same heading. +/// What the file is about: the heading above it, or else the first column +/// heading of a table. Empty for a code block without a heading above it. /// The format this content is written as. /// The finished file content. public sealed record MessageFile(int Ordinal, string Caption, FileExportFormat Format, string Content); \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PlainFileExport.cs b/app/MindWork AI Studio/Tools/PlainFileExport.cs index 657cd576..2405b963 100644 --- a/app/MindWork AI Studio/Tools/PlainFileExport.cs +++ b/app/MindWork AI Studio/Tools/PlainFileExport.cs @@ -16,17 +16,20 @@ public static class PlainFileExport private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(PlainFileExport).Namespace, nameof(PlainFileExport)); /// - /// Reads every table a message holds, in the order they appear in it. + /// Reads every file a message holds, in the order they appear in it. /// /// - /// Two kinds of tables end up in an answer. Almost always it is a Markdown table written with - /// pipes, which is what a model produces on its own; we turn its cells into a file. Rarely a - /// model answers with a fenced code block marked as csv or tsv, which already is the finished - /// file: we hand that through untouched rather than taking it apart and reassembling it. + /// Two kinds of files end up in an answer. Almost always it is a Markdown table written with + /// pipes, which is what a model produces on its own; we turn its cells into a file. Besides, + /// a model answers with a fenced code block marked as a format we write, such as html, latex, + /// markdown, or csv, whenever it was asked for a web page, a document, or data. Such a block + /// already is the finished file: we hand it through untouched rather than taking it apart and + /// reassembling it. We do not judge what the block holds, either. A browser shows a fragment of + /// HTML just as well as an entire page, and a LaTeX fragment is still what the user asked for. /// /// The Markdown text of the message. /// The separator to write a Markdown table with, see CsvWriter.SeparatorFor. - /// The tables, or an empty list when the message holds none. + /// The files, or an empty list when the message holds none. public static IReadOnlyList ExtractFiles(string markdown, char separator) { if (string.IsNullOrWhiteSpace(markdown)) @@ -40,9 +43,10 @@ public static class PlainFileExport var document = Markdig.Markdown.Parse(markdown, Markdown.SAFE_MARKDOWN_PIPELINE); // - // What a table is about stands above it, not in it: models introduce their tables with a - // heading. We remember every heading with its line so that each table can take the last - // one before it, and fall back to its own first column heading when there is none. + // What a file is about stands above it, not in it: models introduce their tables and code + // blocks with a heading. We remember every heading with its line so that each file can take + // the last one before it. A table falls back to its own first column heading when there is + // none; a code block has nothing comparable and stays without a caption. // var headings = document.Descendants() .Select(heading => (heading.Line, Text: ToPlainText(heading))) @@ -56,11 +60,18 @@ public static class PlainFileExport var codeBlocks = document.Descendants() .Select(block => (block.Line, Content: ToContent(block))); + // + // Tables and code blocks are counted apart. The menu falls back to that number when a + // heading cannot tell two files apart, and "Table 2" has to be the second table of the + // answer, not the second entry of the menu. + // + var numberOfTables = 0; + var numberOfCodeBlocks = 0; return tables.Concat(codeBlocks) .Where(entry => entry.Content is not null) .OrderBy(entry => entry.Line) - .Select((entry, index) => new MessageFile( - index + 1, + .Select(entry => new MessageFile( + entry.Content!.Value.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks, Caption: HeadingAbove(entry.Line) is { Length: > 0 } heading ? heading : entry.Content!.Value.Fallback, entry.Content!.Value.Format, entry.Content.Value.Text)) @@ -90,14 +101,22 @@ public static class PlainFileExport } /// - /// Turns a fenced code block into a file, when the model marked it as tabular data. + /// Turns a fenced code block into a file, when the model marked it as a format we write. /// + /// + /// A block the model never closed is left out. That happens when an answer broke off, at the + /// output limit of the model for example, and the file would end wherever the answer did: half + /// a web page or half a table is nothing anybody wants to save. + /// private static (string Fallback, FileExportFormat Format, string Text)? ToContent(FencedCodeBlock block) { - if (!FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format) || format is not (FileExportFormat.CSV or FileExportFormat.TSV)) + if (block.ClosingFencedCharCount is 0 || !FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format)) return null; var content = block.Lines.ToString(); + if (!format.IsTabular()) + return (string.Empty, format, content); + var blockSeparator = format is FileExportFormat.TSV ? '\t' : ','; var firstLine = content.AsSpan(); var lineEnd = firstLine.IndexOf('\n'); diff --git a/app/Tests/Tools/Fixtures/standalone_page.html b/app/Tests/Tools/Fixtures/standalone_page.html new file mode 100644 index 00000000..72b1cc4c --- /dev/null +++ b/app/Tests/Tools/Fixtures/standalone_page.html @@ -0,0 +1,201 @@ + + + + + +Hello World + + + + + + +
+ + + + + + +
+

+
+ + + + + + +
+ + + + \ No newline at end of file diff --git a/app/Tests/Tools/PlainFileExportTests.cs b/app/Tests/Tools/PlainFileExportTests.cs new file mode 100644 index 00000000..8a41bebd --- /dev/null +++ b/app/Tests/Tools/PlainFileExportTests.cs @@ -0,0 +1,134 @@ +using System.Runtime.CompilerServices; + +using AIStudio.Tools; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks which files the export menu finds in an answer. +/// +/// +/// Asked for a web page, a model answers with a code block marked as html, and the same goes for a +/// LaTeX document or a Markdown text. That block already is the file the user wants. Converted along +/// with the rest of the answer, Pandoc shows it as a listing of source code instead, which is what +/// PR #993 reported. The fixture is the page attached to that PR, as the model wrote it. +/// +[TestFixture] +public sealed class PlainFileExportTests +{ + private static readonly string PAGE = ReadFixture("standalone_page.html"); + + [Test] + public void AnAnswerMadeOfOneWebPageOffersThatPage() + { + var files = PlainFileExport.ExtractFiles(Lines("```html", PAGE, "```"), ','); + + Assert.Multiple(() => + { + Assert.That(files, Has.Count.EqualTo(1)); + Assert.That(files[0].Format, Is.EqualTo(FileExportFormat.HTML)); + Assert.That(files[0].Content, Is.EqualTo(PAGE), "The page leaves the answer exactly as the model wrote it, without the fence around it."); + Assert.That(files[0].Caption, Is.Empty, "Without a heading above it, a code block has nothing to be named after."); + }); + } + + [Test] + public void AWebPageAmidExplanationsIsOfferedAsWell() + { + var answer = Lines("Here is your page:", string.Empty, "```html", PAGE, "```", string.Empty, "Save it and open it in your browser."); + + var files = PlainFileExport.ExtractFiles(answer, ','); + + Assert.Multiple(() => + { + Assert.That(files, Has.Count.EqualTo(1), "Models rarely answer with the block alone, so the text around it must not hide it."); + Assert.That(files[0].Content, Is.EqualTo(PAGE)); + }); + } + + [TestCase("HTML", FileExportFormat.HTML)] + [TestCase("tex", FileExportFormat.LATEX)] + [TestCase("markdown", FileExportFormat.MARKDOWN)] + [TestCase("html title=\"index.html\"", FileExportFormat.HTML, Description = "Whatever follows the language is an argument, not part of it.")] + public void ACodeBlockIsOfferedInTheFormatItsLanguageNames(string infoString, FileExportFormat expectedFormat) + { + var files = PlainFileExport.ExtractFiles(Lines($"```{infoString}", "The content.", "```"), ','); + + Assert.Multiple(() => + { + Assert.That(files, Has.Count.EqualTo(1)); + Assert.That(files[0].Format, Is.EqualTo(expectedFormat)); + Assert.That(files[0].Content, Is.EqualTo("The content.")); + }); + } + + [Test] + public void ATildeFenceIsOfferedAsWell() + { + var files = PlainFileExport.ExtractFiles(Lines("~~~latex", @"\section{Results}", "~~~"), ','); + + Assert.That(files.Select(file => file.Format), Is.EqualTo(new[] { FileExportFormat.LATEX })); + } + + [TestCase("```css", TestName = "A language AI Studio writes no file for")] + [TestCase("```", TestName = "A fence without a language")] + public void AnyOtherCodeBlockIsNotOffered(string openingFence) + { + var files = PlainFileExport.ExtractFiles(Lines(openingFence, "body { margin: 0; }", "```"), ','); + + Assert.That(files, Is.Empty); + } + + [TestCase("```html", "

The answer broke off here", TestName = "Half a web page")] + [TestCase("```csv", "Quarter,Revenue", TestName = "Half a table")] + public void ACodeBlockTheModelNeverClosedIsNotOffered(string openingFence, string content) + { + var files = PlainFileExport.ExtractFiles(Lines("The answer starts normally.", string.Empty, openingFence, content), ','); + + Assert.That(files, Is.Empty, "The file would end wherever the answer broke off."); + } + + [Test] + public void TablesAndCodeBlocksAreCountedApart() + { + var answer = Lines( + "# Revenue", + string.Empty, + "| Quarter | Revenue |", + "|---|---|", + "| Q1 | 100 |", + string.Empty, + "# Landing page", + string.Empty, + "```html", + "

First block

", + "```", + string.Empty, + "```latex", + @"\section{Second block}", + "```"); + + var files = PlainFileExport.ExtractFiles(answer, ','); + + Assert.That(files.Select(file => (file.Ordinal, file.Caption, file.Format)), Is.EqualTo(new[] + { + (1, "Revenue", FileExportFormat.CSV), + (1, "Landing page", FileExportFormat.HTML), + (2, "Landing page", FileExportFormat.LATEX), + }), "The first code block is code block 1, even though a table stands before it."); + } + + private static string Lines(params string[] lines) => string.Join(Environment.NewLine, lines); + + /// + /// Reads a file from the fixtures next to this test. + /// + /// + /// Read from the source tree, the way the capability snapshot is, so the fixture needs no entry in + /// the project file. A checkout on Windows may have turned its line ends into CRLF, which the + /// model never wrote. + /// + private static string ReadFixture(string fileName, [CallerFilePath] string sourceFilePath = "") => File + .ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "Fixtures", fileName)) + .Replace("\r\n", "\n"); +} \ No newline at end of file