From 469cbf6bd2e1f057c4e7bb6272b538b0b70e4d43 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Sat, 24 Oct 2026 17:43:29 +0200 Subject: [PATCH] fix(encyclopedia): compact plan JSON, sanitized note lines, robust state read Co-Authored-By: Claude Opus 5.5 --- .../Encyclopedia/ArticlePlan.cs | 33 ++++++++++------- .../Encyclopedia/EncyclopediaState.cs | 6 ++-- .../Encyclopedia/PlanArticlesUseCase.cs | 7 ++-- src/Wiki.Application/Encyclopedia/Prompts.cs | 4 +-- .../EncyclopediaStateTests.cs | 14 ++++++++ .../PlanArticlesUseCaseTests.cs | 36 +++++++++++++++++++ 6 files changed, 81 insertions(+), 19 deletions(-) diff --git a/src/Wiki.Application/Encyclopedia/ArticlePlan.cs b/src/Wiki.Application/Encyclopedia/ArticlePlan.cs index af0905e..dc42939 100644 --- a/src/Wiki.Application/Encyclopedia/ArticlePlan.cs +++ b/src/Wiki.Application/Encyclopedia/ArticlePlan.cs @@ -19,19 +19,24 @@ public static class ArticlePlanJson { private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { WriteIndented = true }; - public static string Write(ArticlePlan p) + private static readonly JsonSerializerOptions CompactOptions = new(JsonSerializerDefaults.Web); + + public static string Write(ArticlePlan p) => + JsonSerializer.Serialize(new { Articles = Shape(p), p.CoveredNotes }, Options); + + /// Only the articles array, compact: the form the model sees and answers in. + public static string WriteArticlesCompact(ArticlePlan p) => + JsonSerializer.Serialize(Shape(p), CompactOptions); + + private static IEnumerable Shape(ArticlePlan p) => p.Articles.Select(a => new { - var articles = p.Articles.Select(a => new - { - a.Slug, - a.TitleDe, - a.TitleEn, - Category = a.Category.ToString().ToLowerInvariant(), - a.Sources, - a.Related, - }); - return JsonSerializer.Serialize(new { Articles = articles, p.CoveredNotes }, Options); - } + a.Slug, + a.TitleDe, + a.TitleEn, + Category = a.Category.ToString().ToLowerInvariant(), + a.Sources, + a.Related, + }); /// /// The plan in , which may be wrapped in a code fence; null when it is not a plan. @@ -47,7 +52,9 @@ public static class ArticlePlanJson { if (item is not JsonObject o) continue; var slug = Text(o["slug"]); - if (slug is null || !Enum.TryParse(Text(o["category"]), ignoreCase: true, out var category)) continue; + var categoryText = Text(o["category"]); + if (slug is null || categoryText is null || !categoryText.All(char.IsAsciiLetter) + || !Enum.TryParse(categoryText, ignoreCase: true, out var category)) continue; articles.Add(new PlannedArticle(slug, Text(o["titleDe"]) ?? slug, Text(o["titleEn"]) ?? slug, category, Strings(o["sources"]), Strings(o["related"]))); } diff --git a/src/Wiki.Application/Encyclopedia/EncyclopediaState.cs b/src/Wiki.Application/Encyclopedia/EncyclopediaState.cs index 8faa1d4..4705da7 100644 --- a/src/Wiki.Application/Encyclopedia/EncyclopediaState.cs +++ b/src/Wiki.Application/Encyclopedia/EncyclopediaState.cs @@ -24,7 +24,7 @@ public sealed record PlanAttempt(string NoteSetFingerprint, DateTimeOffset At); /// public sealed record EncyclopediaState(IReadOnlyDictionary Subjects, PlanAttempt? FailedPlan) { - public static EncyclopediaState Empty { get; } = new(new Dictionary(), null); + public static EncyclopediaState Empty => new(new Dictionary(), null); public static string NoteSetFingerprint(IReadOnlyList notes) => Hash(Prompts.Version.ToString(), notes.Select(n => n.Slug).Order(StringComparer.Ordinal)); @@ -60,7 +60,9 @@ public static class EncyclopediaStateJson try { var s = JsonSerializer.Deserialize(json, Options); - return s?.Subjects is null ? EncyclopediaState.Empty : s; + if (s?.Subjects is null) return EncyclopediaState.Empty; + var subjects = s.Subjects.Where(kv => kv.Value is not null).ToDictionary(kv => kv.Key, kv => kv.Value); + return new EncyclopediaState(subjects, s.FailedPlan); } catch (JsonException) { diff --git a/src/Wiki.Application/Encyclopedia/PlanArticlesUseCase.cs b/src/Wiki.Application/Encyclopedia/PlanArticlesUseCase.cs index ece421b..b1aaf70 100644 --- a/src/Wiki.Application/Encyclopedia/PlanArticlesUseCase.cs +++ b/src/Wiki.Application/Encyclopedia/PlanArticlesUseCase.cs @@ -120,11 +120,14 @@ public sealed partial class PlanArticlesUseCase(ITextModel model, EncyclopediaSe { var sb = new StringBuilder(); if (current is not null) - sb.Append("Current plan:\n").Append(ArticlePlanJson.Write(current)).Append("\n\n"); + sb.Append("Current plan:\n").Append(ArticlePlanJson.WriteArticlesCompact(current)).Append("\n\n"); sb.Append("Notes (slug | title | tags | body length in chars):\n"); foreach (var n in notes.OrderBy(n => n.Slug, StringComparer.Ordinal)) - sb.Append(n.Slug).Append(" | ").Append(n.Title).Append(" | ").Append(string.Join(", ", n.Tags)) + sb.Append(n.Slug).Append(" | ").Append(Flatten(n.Title)).Append(" | ").Append(Flatten(string.Join(", ", n.Tags))) .Append(" | ").Append(n.Body.Length).Append('\n'); return sb.ToString(); } + + /// Keeps a field on its line and inside its column. + private static string Flatten(string s) => s.Replace('\r', ' ').Replace('\n', ' ').Replace('|', ' '); } diff --git a/src/Wiki.Application/Encyclopedia/Prompts.cs b/src/Wiki.Application/Encyclopedia/Prompts.cs index 4880ecc..65edb03 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 = 1; + public const int Version = 2; /// Placeholder in replaced by MaxSourceCharsPerArticle. public const string MaxSourceCharsToken = "{maxSourceChars}"; @@ -25,7 +25,7 @@ public static class Prompts - 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. - Reply with JSON only, no prose and no code fence, in this shape: + Reply with JSON only, no prose and no code fence. Write it compact on a single line, with no indentation, no line breaks and no spaces between tokens, because the reply is size-limited. Shape: {"articles":[{"slug":"...","titleDe":"...","titleEn":"...","category":"room|system|device|topic","sources":["note-slug"],"related":["article-slug"]}]} """; } diff --git a/test/Wiki.Application.Tests/EncyclopediaStateTests.cs b/test/Wiki.Application.Tests/EncyclopediaStateTests.cs index 5a5268a..e0561f4 100644 --- a/test/Wiki.Application.Tests/EncyclopediaStateTests.cs +++ b/test/Wiki.Application.Tests/EncyclopediaStateTests.cs @@ -32,6 +32,20 @@ public class EncyclopediaStateTests Assert.Null(read.FailedPlan); } + [Fact] + public void EncyclopediaStateJson_NullEntry_Dropped() + { + var read = EncyclopediaStateJson.Read("""{"subjects":{"a":null,"b":{"fingerprint":"f","outcome":"written","at":"2026-10-24T03:00:00+00:00"}},"failedPlan":null}"""); + Assert.Equal(["b"], read.Subjects.Keys); + } + + [Fact] + public void Empty_DoesNotShareMutableState() + { + ((Dictionary)EncyclopediaState.Empty.Subjects)["x"] = new("f", AttemptOutcome.Written, At); + Assert.Empty(EncyclopediaState.Empty.Subjects); + } + [Fact] public void SubjectFingerprint_ChangesWithSourceHashOrTitle() { diff --git a/test/Wiki.Application.Tests/PlanArticlesUseCaseTests.cs b/test/Wiki.Application.Tests/PlanArticlesUseCaseTests.cs index 939c8ab..3e7ccee 100644 --- a/test/Wiki.Application.Tests/PlanArticlesUseCaseTests.cs +++ b/test/Wiki.Application.Tests/PlanArticlesUseCaseTests.cs @@ -133,6 +133,42 @@ public class PlanArticlesUseCaseTests Assert.Equal(Settings.PlanMaxTokens, call.MaxTokens); } + [Fact] + public async Task Plan_PromptAsksForCompactJson_AndOmitsCoveredNotes() + { + var model = new FakeTextModel().Reply("nope"); + var current = new ArticlePlan([new PlannedArticle("heating-valves", "H", "H", ArticleCategory.Device, ["a"], [])], ["a", "covered-only"]); + + await UseCase(model).PlanAsync(current, [Note("a")], CancellationToken.None); + + var call = Assert.Single(model.Calls); + Assert.Contains("compact", call.System); + Assert.DoesNotContain("coveredNotes", call.User); + Assert.DoesNotContain("covered-only", call.User); + var jsonLine = call.User.Split('\n').Single(l => l.StartsWith("[{")); + Assert.Equal("""[{"slug":"heating-valves","titleDe":"H","titleEn":"H","category":"device","sources":["a"],"related":[]}]""", jsonLine); + } + + [Fact] + public async Task Plan_TitleWithPipeOrNewline_Sanitized() + { + var model = new FakeTextModel().Reply("nope"); + var note = Note("a", title: "Left | Right\nSecond line"); + + await UseCase(model).PlanAsync(null, [note], CancellationToken.None); + + var line = Assert.Single(model.Calls[0].User.Split('\n'), l => l.StartsWith("a | ")); + Assert.Equal(4, line.Split(" | ").Length); + Assert.Contains("Left Right Second line", line); + } + + [Fact] + public void ArticlePlanJson_NumericCategory_ArticleSkipped() + { + var plan = ArticlePlanJson.Read("""{"articles":[{"slug":"a","category":"1","sources":["x"]},{"slug":"b","category":"room","sources":["x"]}]}"""); + Assert.Equal(["b"], plan!.Articles.Select(a => a.Slug)); + } + [Fact] public void ArticlePlanJson_Malformed_ReadsNull() {