From ca12438ebbe94a543ed2cb7305be0b0b097ea6c5 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sun, 25 Oct 2026 15:10:09 +0100 Subject: [PATCH] fix(encyclopedia): leak-check slugs and titles, keep planned titles stable A scanner hit in the slug or titles, or the German leak check flagging them, makes the article private in both languages. The plan prompt forbids concrete identifiers in titles and slugs and keeps existing titles; prompt version 5. Co-Authored-By: Claude Opus 5.5 --- src/Wiki.Application/Encyclopedia/Prompts.cs | 4 +- .../Encyclopedia/WriteArticleUseCase.cs | 36 ++++++++++++------ .../PlanArticlesUseCaseTests.cs | 12 ++++++ .../WriteArticleUseCaseTests.cs | 37 +++++++++++++++++-- 4 files changed, 73 insertions(+), 16 deletions(-) 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);