mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-14 15:43:37 +00:00
Fixed exports and the clipboard losing the sources of an answer (#966)
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
Some checks are pending
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions
This commit is contained in:
parent
bb3ec5da92
commit
65a1683b95
@ -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"
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -24,7 +24,9 @@ public static class IContentExtensions
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="content">The content to read.</param>
|
||||
/// <param name="markdown">The Markdown text, or an empty string when there is none.</param>
|
||||
@ -40,4 +42,48 @@ public static class IContentExtensions
|
||||
markdown = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads this content the way it leaves AI Studio, as a file or through the clipboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="content">The content to read.</param>
|
||||
/// <param name="markdown">The Markdown text including its sources, or an empty string when there is none.</param>
|
||||
/// <returns>True, when this content carries Markdown text.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -61,6 +61,11 @@ public partial class MudCopyClipboardButton : ComponentBase
|
||||
/// <summary>
|
||||
/// Copy this block's content to the clipboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private async Task CopyToClipboard(IContent? contentToCopy)
|
||||
{
|
||||
if (contentToCopy is null)
|
||||
@ -68,9 +73,8 @@ public partial class MudCopyClipboardButton : ComponentBase
|
||||
|
||||
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:
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/// <summary>Closes a code fence which the text opened but never closed.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="markdownText">The Markdown text to inspect.</param>
|
||||
/// <returns>The text with its open fence closed, or the text itself when no fence is open.</returns>
|
||||
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<FencedCodeBlock>().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))
|
||||
|
||||
@ -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.")));
|
||||
|
||||
@ -153,6 +153,26 @@ public static partial class SourceExtensions
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a list of sources to a markdown-formatted string, headed by a title of its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="sources">The list of sources to convert.</param>
|
||||
/// <returns>A markdown-formatted string representing the sources, or an empty string when there are none.</returns>
|
||||
public static string ToExportMarkdown(this IList<Source> sources)
|
||||
{
|
||||
var sourcesMarkdown = sources.ToMarkdown();
|
||||
if (string.IsNullOrWhiteSpace(sourcesMarkdown))
|
||||
return string.Empty;
|
||||
|
||||
return $"# {TB("Sources")}{Environment.NewLine}{Environment.NewLine}{sourcesMarkdown}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs.
|
||||
/// </summary>
|
||||
|
||||
@ -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.
|
||||
|
||||
205
app/Tests/Chat/IContentExtensionsTests.cs
Normal file
205
app/Tests/Chat/IContentExtensionsTests.cs
Normal file
@ -0,0 +1,205 @@
|
||||
using AIStudio.Chat;
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Markdig.Syntax;
|
||||
|
||||
namespace AIStudio.Tests.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that an answer leaves AI Studio together with the sources it rests on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A text message with the given sources hanging on it.
|
||||
/// </summary>
|
||||
/// <param name="text">The text the model wrote.</param>
|
||||
/// <param name="sources">The sources AI Studio collected for it.</param>
|
||||
/// <returns>The content.</returns>
|
||||
private static ContentText TextWith(string text, params Source[] sources) => new()
|
||||
{
|
||||
Text = text,
|
||||
Sources = [..sources],
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Names the blocks a Markdown text is made of, headings by their level.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="markdown">The Markdown text to read.</param>
|
||||
/// <returns>The names, in the order the blocks stand in.</returns>
|
||||
private static IReadOnlyList<string> 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);
|
||||
}
|
||||
66
app/Tests/Tools/MarkdownTests.cs
Normal file
66
app/Tests/Tools/MarkdownTests.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using AIStudio.Tools;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that an answer which opens a code fence without closing it ends where it ends.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
97
app/Tests/Tools/SourceExtensionsTests.cs
Normal file
97
app/Tests/Tools/SourceExtensionsTests.cs
Normal file
@ -0,0 +1,97 @@
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Markdig.Syntax;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the list of sources reads once it is written out as Markdown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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<Source> 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](<https://example.org/cited>)",
|
||||
"- [2] [Search result](<https://example.org/search>)",
|
||||
"- [3] [Handbook](<https://example.org/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<Source> 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](<https://example.org/>)"), "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<Source> 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)](<file:///Users/someone/My%20Documents/handbook.pdf>)"), "A space would end the link destination, so it is escaped.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void NoSourcesMeanNoText()
|
||||
{
|
||||
IList<Source> 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<Source> 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<HeadingBlock>().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.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the entries of a source list, without the headings above them.
|
||||
/// </summary>
|
||||
/// <param name="markdown">The Markdown of the sources.</param>
|
||||
/// <returns>The entries, in the order they stand in.</returns>
|
||||
private static IReadOnlyList<string> EntriesOf(string markdown) => markdown
|
||||
.Split(Environment.NewLine, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
|
||||
.Where(line => line.StartsWith("- [", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user