diff --git a/app/MindWork AI Studio.slnx.DotSettings b/app/MindWork AI Studio.slnx.DotSettings index cad432066..946c944e5 100644 --- a/app/MindWork AI Studio.slnx.DotSettings +++ b/app/MindWork AI Studio.slnx.DotSettings @@ -13,6 +13,7 @@ IERI IMIME IONOS + LF LLM LM MSG diff --git a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflReflectionFolder.cs b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflReflectionFolder.cs index 9a5af3156..f6ac62ab5 100644 --- a/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflReflectionFolder.cs +++ b/app/MindWork AI Studio/Assistants/BeyondFactsLab/BflReflectionFolder.cs @@ -2,6 +2,8 @@ using System.Text; using AIStudio.Chat; +using SharedTools; + namespace AIStudio.Assistants.BeyondFactsLab; /// @@ -12,7 +14,7 @@ namespace AIStudio.Assistants.BeyondFactsLab; /// No model takes part. The person confirmed every work state word for word, so the folder repeats them word for word /// instead of having a model rewrite them. A step without a confirmed work state is named, not filled. Personal /// disclosures stay out unless the person includes them, and so does the conversation of a step which may contain -/// some: the folder is meant to be passed on. +/// some: the folder is meant to be passed on. Every line ends with LF, on every system. /// public static class BflReflectionFolder { @@ -33,7 +35,7 @@ public static class BflReflectionFolder public static string Build(BflReflectionFolderInput input) { var text = new StringBuilder(); - text.Append("# ").Append(Markdown.EscapeInlineText(BflResultKind.REFLECTION_FOLDER.Title)).Append(": ").AppendLine(Markdown.EscapeInlineText(input.ProjectName)); + text.Append("# ").Append(Markdown.EscapeInlineText(BflResultKind.REFLECTION_FOLDER.Title)).Append(": ").AppendLineLF(Markdown.EscapeInlineText(input.ProjectName)); foreach (var step in BflStep.All) AppendStep(text, step, input); @@ -78,15 +80,15 @@ public static class BflReflectionFolder /// What the folder is put together from. private static void AppendStep(StringBuilder text, BflStep step, BflReflectionFolderInput input) { - text.AppendLine().Append("## ").AppendLine(Markdown.EscapeInlineText(step.Title)).AppendLine(); + text.AppendLineLF().Append("## ").AppendLineLF(Markdown.EscapeInlineText(step.Title)).AppendLineLF(); if (input.WorkStates.GetValueOrDefault(step)?.Confirmed is not { } confirmed) { - text.Append('_').Append(Markdown.EscapeInlineText(TB("No confirmed work state."))).AppendLine("_"); + text.Append('_').Append(Markdown.EscapeInlineText(TB("No confirmed work state."))).AppendLineLF("_"); return; } var confirmedAt = confirmed.ConfirmedAtUtc.ToLocalTime().ToString("d", input.Culture); - text.Append('_').Append(Markdown.EscapeInlineText(string.Format(input.Culture, TB("Version {0}, confirmed on {1}."), confirmed.Revision, confirmedAt))).AppendLine("_"); + text.Append('_').Append(Markdown.EscapeInlineText(string.Format(input.Culture, TB("Version {0}, confirmed on {1}."), confirmed.Revision, confirmedAt))).AppendLineLF("_"); var notice = BflStaleness.Of(step, input.WorkStates, input.Forms) switch { BflWorkStateStatus.OUTDATED => TB("An earlier step changed after this confirmation."), @@ -95,14 +97,14 @@ public static class BflReflectionFolder }; if (notice.Length > 0) - text.AppendLine().Append('_').Append(Markdown.EscapeInlineText(notice)).AppendLine("_"); + text.AppendLineLF().Append('_').Append(Markdown.EscapeInlineText(notice)).AppendLineLF("_"); foreach (var section in BflWorkStateSections.ForStep(step)) { if (section.Key is OPEN_POINTS_KEY || section.IsPersonalDisclosure && !input.IncludePersonalDisclosures || !confirmed.Sections.TryGetValue(section.Key, out var sectionText) || string.IsNullOrWhiteSpace(sectionText)) continue; - text.AppendLine().Append("### ").AppendLine(Markdown.EscapeInlineText(section.Title)).AppendLine().AppendLine(sectionText.Trim()); + text.AppendLineLF().Append("### ").AppendLineLF(Markdown.EscapeInlineText(section.Title)).AppendLineLF().AppendLineLF(WithLineBreaks(sectionText)); } } @@ -113,7 +115,7 @@ public static class BflReflectionFolder /// What the folder is put together from. private static void AppendOpenPoints(StringBuilder text, BflReflectionFolderInput input) { - text.AppendLine().Append("## ").AppendLine(Markdown.EscapeInlineText(TB("Open questions"))).AppendLine(); + text.AppendLineLF().Append("## ").AppendLineLF(Markdown.EscapeInlineText(TB("Open questions"))).AppendLineLF(); var any = false; foreach (var step in BflStep.All) { @@ -121,11 +123,11 @@ public static class BflReflectionFolder continue; any = true; - text.Append("### ").AppendLine(Markdown.EscapeInlineText(step.Title)).AppendLine().AppendLine(openPoints.Trim()).AppendLine(); + text.Append("### ").AppendLineLF(Markdown.EscapeInlineText(step.Title)).AppendLineLF().AppendLineLF(WithLineBreaks(openPoints)).AppendLineLF(); } if (!any) - text.Append('_').Append(Markdown.EscapeInlineText(TB("No confirmed work state names open questions."))).AppendLine("_"); + text.Append('_').Append(Markdown.EscapeInlineText(TB("No confirmed work state names open questions."))).AppendLineLF("_"); } /// @@ -135,15 +137,15 @@ public static class BflReflectionFolder /// What the folder is put together from. private static void AppendMaterials(StringBuilder text, BflReflectionFolderInput input) { - text.AppendLine().Append("## ").AppendLine(Markdown.EscapeInlineText(TB("Material"))).AppendLine(); + text.AppendLineLF().Append("## ").AppendLineLF(Markdown.EscapeInlineText(TB("Material"))).AppendLineLF(); if (input.Materials.Count is 0) { - text.Append('_').Append(Markdown.EscapeInlineText(TB("No material was added."))).AppendLine("_"); + text.Append('_').Append(Markdown.EscapeInlineText(TB("No material was added."))).AppendLineLF("_"); return; } foreach (var material in input.Materials.OrderBy(material => material.AddedAtUtc)) - text.Append("- ").Append(Markdown.EscapeInlineText(material.Name)).Append(" (").Append(Markdown.EscapeInlineText(KindName(material.Kind))).AppendLine(")"); + text.Append("- ").Append(Markdown.EscapeInlineText(material.Name)).Append(" (").Append(Markdown.EscapeInlineText(KindName(material.Kind))).AppendLineLF(")"); } /// @@ -153,27 +155,36 @@ public static class BflReflectionFolder /// What the folder is put together from. private static void AppendConversations(StringBuilder text, BflReflectionFolderInput input) { - text.AppendLine().Append("## ").AppendLine(Markdown.EscapeInlineText(TB("Appendix: the conversations"))).AppendLine(); + text.AppendLineLF().Append("## ").AppendLineLF(Markdown.EscapeInlineText(TB("Appendix: the conversations"))).AppendLineLF(); foreach (var step in BflStep.All) { if (!input.Conversations.TryGetValue(step, out var messages) || messages.Count is 0) continue; - text.Append("### ").AppendLine(Markdown.EscapeInlineText(step.Title)).AppendLine(); + text.Append("### ").AppendLineLF(Markdown.EscapeInlineText(step.Title)).AppendLineLF(); if (!input.IncludePersonalDisclosures && MayHoldPersonalDisclosures(step)) { - text.Append('_').Append(Markdown.EscapeInlineText(TB("Left out, as it may contain personal disclosures."))).AppendLine("_").AppendLine(); + text.Append('_').Append(Markdown.EscapeInlineText(TB("Left out, as it may contain personal disclosures."))).AppendLineLF("_").AppendLineLF(); continue; } foreach (var message in messages) { var speaker = message.IsFromPerson ? TA("You") : TB("Lab"); - text.Append("**").Append(Markdown.EscapeInlineText(speaker)).AppendLine(":**").AppendLine().AppendLine(message.Text).AppendLine(); + text.Append("**").Append(Markdown.EscapeInlineText(speaker)).AppendLineLF(":**").AppendLineLF().AppendLineLF(WithLineBreaks(message.Text)).AppendLineLF(); } } } + /// + /// Gets a text which the person or a model wrote, trimmed and with LF line breaks. + /// + /// + /// A model may answer with CR LF; without this, the folder would mix two kinds of line breaks. + /// + /// The text. + private static string WithLineBreaks(string text) => text.Trim().ReplaceLineEndings("\n"); + /// /// Gets the name of a kind of material. /// diff --git a/app/SharedTools/StringBuilderExtensions.cs b/app/SharedTools/StringBuilderExtensions.cs new file mode 100644 index 000000000..aad6a8b5c --- /dev/null +++ b/app/SharedTools/StringBuilderExtensions.cs @@ -0,0 +1,45 @@ +using System.Runtime.CompilerServices; +using System.Text; + +namespace SharedTools; + +/// +/// Appends lines with LF, the same line break on every system. +/// +/// +/// StringBuilder.AppendLine writes Environment.NewLine, which is CR LF on Windows: a text built with it differs between +/// the systems, and it mixes two kinds of line breaks as soon as one of its parts comes with LF. These members carry a +/// name of their own, because C# always prefers the instance method AppendLine over an extension of the same name. +/// +public static class StringBuilderExtensions +{ + private const char LINE_FEED = '\n'; + + /// The builder to append to. + extension(StringBuilder builder) + { + /// + /// Appends a line break, LF. + /// + /// The builder, to append more. + public StringBuilder AppendLineLF() => builder.Append(LINE_FEED); + + /// + /// Appends a text and a line break, LF. + /// + /// The text; for null, only the line break. + /// The builder, to append more. + public StringBuilder AppendLineLF(string? value) => builder.Append(value).Append(LINE_FEED); + + /// + /// Appends an interpolated text and a line break, LF. + /// + /// + /// Like StringBuilder.AppendLine, the handler writes the text right into the builder, without a string in + /// between; afterward, only the line break is missing. + /// + /// The handler which the compiler creates for the interpolated text. + /// The builder, to append more. + public StringBuilder AppendLineLF([InterpolatedStringHandlerArgument("builder")] ref StringBuilder.AppendInterpolatedStringHandler handler) => builder.Append(LINE_FEED); + } +} \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflPromptComposerTests.cs b/app/Tests/BeyondFactsLab/BflPromptComposerTests.cs index 3ba7d99db..96c4c7c9d 100644 --- a/app/Tests/BeyondFactsLab/BflPromptComposerTests.cs +++ b/app/Tests/BeyondFactsLab/BflPromptComposerTests.cs @@ -46,8 +46,8 @@ public sealed class BflPromptComposerTests using (Assert.EnterMultipleScope()) { Assert.That(prompt, Does.Not.Contain("{{"), step.ToString()); - Assert.That(prompt, Does.Contain(parts.Step.Trim()), step.ToString()); - Assert.That(prompt, Does.Contain(parts.Knowledge.Trim()[..200]), step.ToString()); + Assert.That(prompt, Does.Contain(WithLf(parts.Step)), step.ToString()); + Assert.That(prompt, Does.Contain(WithLf(parts.Knowledge)[..200]), step.ToString()); } } } @@ -79,7 +79,7 @@ public sealed class BflPromptComposerTests { Assert.That(prompt, Does.Not.Contain("{{")); Assert.That(prompt, Does.Contain("Antworte auf Deutsch.")); - Assert.That(prompt, Does.Not.Contain(knowledge.Trim()[..200]), "The onboarding stays short, so it leaves out the guide."); + Assert.That(prompt, Does.Not.Contain(WithLf(knowledge)[..200]), "The onboarding stays short, so it leaves out the guide."); } } @@ -279,7 +279,7 @@ public sealed class BflPromptComposerTests using (Assert.EnterMultipleScope()) { - Assert.That(prompt.IndexOf(parts.Step.Trim(), StringComparison.Ordinal), Is.LessThan(material), "The material changes less often than the details, so it comes first for the prompt caching."); + Assert.That(prompt.IndexOf(WithLf(parts.Step), StringComparison.Ordinal), Is.GreaterThanOrEqualTo(0).And.LessThan(material), "The material changes less often than the details, so it comes first for the prompt caching."); Assert.That(material, Is.LessThan(prompt.IndexOf("THESE-4711", StringComparison.Ordinal))); } } @@ -330,4 +330,14 @@ public sealed class BflPromptComposerTests } private static BflMaterialExcerpt Excerpt(string name, string text, int textLength) => new(new(new BflMaterial { MaterialId = Guid.NewGuid(), Name = name, TextLength = textLength }, textLength), text); + + /// + /// Gets a file of the lab's prompts as the composer inserts it: trimmed and with LF line breaks. + /// + /// + /// The files keep the line endings of the checkout, CR LF on Windows with core.autocrlf. Compared as they are, a + /// check for a part fails there, and a check for its absence passes without checking anything. + /// + /// The text of the file. + private static string WithLf(string text) => text.ReplaceLineEndings("\n").Trim(); } \ No newline at end of file diff --git a/app/Tests/BeyondFactsLab/BflReflectionFolderTests.cs b/app/Tests/BeyondFactsLab/BflReflectionFolderTests.cs index fe8aab608..ed0a04549 100644 --- a/app/Tests/BeyondFactsLab/BflReflectionFolderTests.cs +++ b/app/Tests/BeyondFactsLab/BflReflectionFolderTests.cs @@ -174,6 +174,27 @@ public sealed class BflReflectionFolderTests Assert.That(folder.Split('\n')[0], Does.StartWith("# ").And.Contain("Panel").And.Contain("Kopf")); } + [Test] + public void EveryLineEndsWithLfAlsoWhenATextComesWithCrLf() + { + var workStates = new Dictionary(); + Confirm(workStates, BflStep.KNOWLEDGE, new() { ["ESTABLISHED"] = "GESICHERT-1\r\nGESICHERT-2", ["OPEN_POINTS"] = "OFFEN-1\r\nOFFEN-2" }); + var conversations = new Dictionary> + { + [BflStep.STRATEGY] = [new(false, "ANTWORT-1\r\n\r\nANTWORT-2")], + }; + + var folder = Build(workStates, conversations: conversations); + + using (Assert.EnterMultipleScope()) + { + Assert.That(folder, Does.Not.Contain("\r"), "The folder must never mix two kinds of line breaks, also not on Windows."); + Assert.That(folder, Does.Contain("GESICHERT-1\nGESICHERT-2\n")); + Assert.That(folder, Does.Contain("OFFEN-1\nOFFEN-2\n")); + Assert.That(folder, Does.Contain("ANTWORT-1\n\nANTWORT-2\n")); + } + } + private static string Build(IReadOnlyDictionary workStates, bool includePersonalDisclosures = false, IReadOnlyDictionary>? conversations = null) => BflReflectionFolder.Build(new BflReflectionFolderInput("Panel", workStates, Forms(), [], conversations ?? new Dictionary>(), includePersonalDisclosures, CultureInfo.InvariantCulture)); private static Dictionary Forms() => BflStep.All.ToDictionary(step => step, _ => new BflStepFormData()); diff --git a/app/Tests/Tools/PluginSystem/I18NSourceScannerTests.cs b/app/Tests/Tools/PluginSystem/I18NSourceScannerTests.cs index f6e337ad7..8e3d06196 100644 --- a/app/Tests/Tools/PluginSystem/I18NSourceScannerTests.cs +++ b/app/Tests/Tools/PluginSystem/I18NSourceScannerTests.cs @@ -101,7 +101,8 @@ public sealed class I18NSourceScannerTests var texts = I18NSourceScanner.FindTexts(SOURCE, "Notice.cs", []); - Assert.That(texts.Select(text => text.Text), Is.EqualTo(new[] { "## Welcome!\n\nThe first paragraph." })); + // The scanner keeps the line endings of the checkout, as the compiler does, and the collector makes them equal: + Assert.That(texts.Select(text => I18NKeyHash.NormalizeLineEndings(text.Text)), Is.EqualTo(new[] { "## Welcome!\n\nThe first paragraph." })); } [Test] diff --git a/app/Tests/Tools/StringBuilderExtensionsTests.cs b/app/Tests/Tools/StringBuilderExtensionsTests.cs new file mode 100644 index 000000000..c9a61d852 --- /dev/null +++ b/app/Tests/Tools/StringBuilderExtensionsTests.cs @@ -0,0 +1,46 @@ +using System.Globalization; +using System.Text; + +using SharedTools; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks that AppendLineLF ends every line with LF, whatever the system. +/// +/// +/// StringBuilder.AppendLine writes CR LF on Windows. These tests would show it there, while on macOS and Linux they +/// pass with both. +/// +[TestFixture] +public sealed class StringBuilderExtensionsTests +{ + [Test] + public void EveryKindOfLineEndsWithLf() + { + var number = 4711; + var text = new StringBuilder() + .AppendLineLF("Erste Zeile") + .AppendLineLF() + .AppendLineLF($"Nummer {number}") + .AppendLineLF(null) + .ToString(); + + Assert.That(text, Is.EqualTo("Erste Zeile\n\nNummer 4711\n\n")); + } + + [Test] + public void AnInterpolatedTextGoesIntoTheSameBuilderInItsPlace() + { + var value = 1.5; + var builder = new StringBuilder("Anfang "); + + var returned = builder.AppendLineLF($"{value.ToString(CultureInfo.InvariantCulture)} und {"Text"}").Append("Ende"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(returned, Is.SameAs(builder)); + Assert.That(builder.ToString(), Is.EqualTo("Anfang 1.5 und Text\nEnde")); + } + } +} \ No newline at end of file