diff --git a/src/Wiki.Application/Encyclopedia/Prompts.cs b/src/Wiki.Application/Encyclopedia/Prompts.cs index aad0cf0..b27687d 100644 --- a/src/Wiki.Application/Encyclopedia/Prompts.cs +++ b/src/Wiki.Application/Encyclopedia/Prompts.cs @@ -6,7 +6,7 @@ namespace Wiki.Application.Encyclopedia; /// public static class Prompts { - public const int Version = 4; + public const int Version = 5; /// Placeholder in replaced by MaxSourceCharsPerArticle. public const string MaxSourceCharsToken = "{maxSourceChars}"; @@ -21,6 +21,8 @@ public static class Prompts - A subject that was set once and forgotten is not an article of its own. Its notes go into the article of its parent system as a section. - Every note is used by at least one article, or is deliberately skipped. Skip only meta notes and notes about the wiki's own tooling. - Keep the existing slugs for existing subjects. Do not rename or renumber them. + - Keep the existing titles for existing subjects unless the subject changed. + - Titles and slugs name nothing concrete in this house: no hostnames, device IDs, addresses or IP addresses. Name the subject by what it is or does, for example "Netzwerkspeicher" rather than the server's hostname. - List each article's sources most important first. When the sources of an article add up to more than {maxSourceChars} characters of body, split the subject into narrower articles. - Slugs are lowercase ASCII words joined by single hyphens. Categories are room, system, device or topic. - related lists the slugs of other articles in this plan that a reader of the article should continue with. diff --git a/src/Wiki.Application/Encyclopedia/WriteArticleUseCase.cs b/src/Wiki.Application/Encyclopedia/WriteArticleUseCase.cs index c359fe5..1c87e93 100644 --- a/src/Wiki.Application/Encyclopedia/WriteArticleUseCase.cs +++ b/src/Wiki.Application/Encyclopedia/WriteArticleUseCase.cs @@ -31,7 +31,8 @@ public interface IArticleWriter /// /// Writes an article in German, privatizes leaks (deterministic filter, then an LLM check of the public blocks), /// translates it unit by unit into English, and filters and checks the English the same way, mirroring every -/// privatization into the German. Anything unexpected rejects the article. +/// privatization into the German. A leak in the slug or titles makes the whole article private. +/// Anything unexpected rejects the article. /// public sealed partial class WriteArticleUseCase(ITextModel model, EncyclopediaSettings settings, TimeProvider clock) : IArticleWriter { @@ -57,14 +58,20 @@ public sealed partial class WriteArticleUseCase(ITextModel model, EncyclopediaSe var (visibility, de) = ParseWritten(written.Text); if (de.Length == 0) throw new ArticleRejectedException(slug, "the article body is empty"); - de = ArticleBody.Privatize(de, await LeakIndexesAsync(slug, de, sourceTexts, ct)); + // the slug and titles are shown to every reader of a public article, so a leak in them makes the article private; + // the slug comes first, so the line is one plain public block + var titleLeak = LeakScanner.Scan(Flatten($"{slug} {planned.TitleDe} {planned.TitleEn}"), sourceTexts).Count > 0; + var (germanLeaks, titleFlagged) = await LeakIndexesAsync(slug, de, sourceTexts, + titleLeak ? null : Flatten($"{slug} — {planned.TitleDe}"), ct); + if (titleLeak || titleFlagged) visibility = ArticleVisibility.Private; + de = ArticleBody.Privatize(de, germanLeaks); var en = await TranslateAsync(slug, de, ct); if (ArticleBody.PublicBlocks(de).Count != ArticleBody.PublicBlocks(en).Count) throw new ArticleRejectedException(slug, "the translation changed the public blocks"); // indexes of the English public blocks are those of the German ones: Reassemble keeps every blank and structural line - var englishLeaks = await LeakIndexesAsync(slug, en, sourceTexts, ct); + var (englishLeaks, _) = await LeakIndexesAsync(slug, en, sourceTexts, null, ct); de = ArticleBody.Privatize(de, englishLeaks); en = ArticleBody.Privatize(en, englishLeaks); if (!ArticleBody.Skeleton(de).SequenceEqual(ArticleBody.Skeleton(en), StringComparer.Ordinal)) @@ -104,27 +111,32 @@ public sealed partial class WriteArticleUseCase(ITextModel model, EncyclopediaSe /// /// Public block indexes of to privatize: the scanner's matches and every block with an /// image (a text model cannot judge a photo), plus the blocks the model flags among the rest, which are all it sees. - /// The model sees those blocks numbered 0..n-1; an index outside that range rejects the article. + /// The model sees those blocks numbered 0..n-1 and, when given, as item n; Title is + /// true when it flags that item. An index outside the range shown rejects the article. /// - private async Task> LeakIndexesAsync(string slug, string body, IReadOnlyList sources, CancellationToken ct) + private async Task<(HashSet Blocks, bool Title)> LeakIndexesAsync(string slug, string body, IReadOnlyList sources, + string? title, CancellationToken ct) { var blocks = ArticleBody.PublicBlocks(body); var flagged = LeakScanner.Scan(body, sources).Select(m => m.BlockIndex).ToHashSet(); flagged.UnionWith(blocks.Where(b => b.Text.Contains("![", StringComparison.Ordinal)).Select(b => b.Index)); var toCheck = blocks.Where(b => !flagged.Contains(b.Index)).ToList(); - if (toCheck.Count == 0) return flagged; + var items = toCheck.Select((b, i) => (i, b.Text)).ToList(); + if (title is not null) items.Add((toCheck.Count, title)); + if (items.Count == 0) return (flagged, false); - var reply = await CompleteAsync(slug, "leak check", Prompts.LeakCheck, - Numbered(toCheck.Select((b, i) => (i, b.Text))), LeakCheckMaxTokens, ct); + var reply = await CompleteAsync(slug, "leak check", Prompts.LeakCheck, Numbered(items), LeakCheckMaxTokens, ct); var chosen = ParseLeakCheck(reply.Text) ?? throw new ArticleRejectedException(slug, "the leak check reply is not {\"privatize\":[…]}"); + var titleFlagged = false; foreach (var i in chosen) { - if (i < 0 || i >= toCheck.Count) - throw new ArticleRejectedException(slug, $"the leak check flagged block {i}, but only {toCheck.Count} were shown"); - flagged.Add(toCheck[i].Index); + if (i < 0 || i >= items.Count) + throw new ArticleRejectedException(slug, $"the leak check flagged block {i}, but only {items.Count} were shown"); + if (i == toCheck.Count) titleFlagged = true; + else flagged.Add(toCheck[i].Index); } - return flagged; + return (flagged, titleFlagged); } /// Public and private units go in separate requests, so no private text can reach a public unit. diff --git a/test/Wiki.Application.Tests/PlanArticlesUseCaseTests.cs b/test/Wiki.Application.Tests/PlanArticlesUseCaseTests.cs index 3e7ccee..eed782a 100644 --- a/test/Wiki.Application.Tests/PlanArticlesUseCaseTests.cs +++ b/test/Wiki.Application.Tests/PlanArticlesUseCaseTests.cs @@ -133,6 +133,18 @@ public class PlanArticlesUseCaseTests Assert.Equal(Settings.PlanMaxTokens, call.MaxTokens); } + [Fact] + public async Task Plan_PromptForbidsConcreteTitles_AndKeepsTitles() + { + var model = new FakeTextModel().Reply("nope"); + + await UseCase(model).PlanAsync(null, [Note("a")], CancellationToken.None); + + var system = Assert.Single(model.Calls).System; + Assert.Contains("Titles and slugs name nothing concrete in this house: no hostnames, device IDs, addresses or IP addresses.", system); + Assert.Contains("Keep the existing titles for existing subjects unless the subject changed.", system); + } + [Fact] public async Task Plan_PromptAsksForCompactJson_AndOmitsCoveredNotes() { diff --git a/test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs b/test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs index 3f7228f..71727d3 100644 --- a/test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs +++ b/test/Wiki.Application.Tests/WriteArticleUseCaseTests.cs @@ -38,8 +38,11 @@ public class WriteArticleUseCaseTests } private static Task Write(FakeTextModel model, params Document[] sources) => + Write(model, Planned, sources); + + private static Task Write(FakeTextModel model, PlannedArticle planned, params Document[] sources) => new WriteArticleUseCase(model, Settings, new FakeTimeProvider(Now)) - .WriteAsync(Planned, sources.Length == 0 ? [Source()] : sources, All, CancellationToken.None); + .WriteAsync(planned, sources.Length == 0 ? [Source()] : sources, All, CancellationToken.None); private static IReadOnlyList Public(string body) => ArticleBody.PublicBlocks(body).Select(b => b.Text).ToList(); @@ -70,8 +73,36 @@ public class WriteArticleUseCaseTests Assert.DoesNotContain("One.", ArticleBody.RedactPrivate(written.En.Body)); } + [Fact] + public async Task Write_TitleWithHouseHostname_ForcesPrivate() + { + var model = Model(ThreeBlocks, Clean, """["Zero.","One.","Two."]""", Clean); + var planned = Planned with { TitleDe = "Server nas-1.lan", TitleEn = "Server nas-1.lan" }; + + var written = await Write(model, planned, Source(body: "Der Server nas-1.lan steht im Keller.")); + + Assert.Equal(ArticleVisibility.Private, written.De.Visibility); + Assert.Equal(ArticleVisibility.Private, written.En.Visibility); + Assert.Equal(["Absatz null.", "Absatz eins.", "Absatz zwei."], Public(written.De.Body)); + } + + [Fact] + public async Task Write_LeakCheckFlagsTitle_ForcesPrivate() + { + var model = Model(ThreeBlocks, """{"privatize":[3]}""", """["Zero.","One.","Two."]""", Clean); + + var written = await Write(model); + + Assert.EndsWith("""{"i":3,"text":"wall-switches — Wandschalter"}]""", model.Calls[1].User); + Assert.DoesNotContain("Wandschalter", model.Calls[^1].User); + Assert.Equal(ArticleVisibility.Private, written.De.Visibility); + Assert.Equal(ArticleVisibility.Private, written.En.Visibility); + Assert.Equal(["Absatz null.", "Absatz eins.", "Absatz zwei."], Public(written.De.Body)); + Assert.Equal(["Zero.", "One.", "Two."], Public(written.En.Body)); + } + [Theory] - [InlineData("""{"privatize":[3]}""")] + [InlineData("""{"privatize":[4]}""")] [InlineData("""{"privatize":[-1]}""")] [InlineData("""{"privatize":[0,7]}""")] public async Task LeakCheck_IndexOutOfRange_Rejected(string reply) @@ -89,7 +120,7 @@ public class WriteArticleUseCaseTests var written = await Write(model, Source(body: "Der Router hat die Adresse 10.2.40.1.")); - Assert.Equal("""[{"i":0,"text":"Absatz null."},{"i":1,"text":"Absatz zwei."}]""", model.Calls[1].User); + Assert.Equal("""[{"i":0,"text":"Absatz null."},{"i":1,"text":"Absatz zwei."},{"i":2,"text":"wall-switches — Wandschalter"}]""", model.Calls[1].User); Assert.Equal(["Absatz null."], Public(written.De.Body)); Assert.Equal(["Zero."], Public(written.En.Body)); Assert.Equal("""[{"i":0,"text":"Zero."}]""", model.Calls[^1].User);