fix(encyclopedia): compact plan JSON, sanitized note lines, robust state read

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 17:43:29 +02:00
co-authored by Claude Opus 5.5
parent 13de42b838
commit 469cbf6bd2
6 changed files with 81 additions and 19 deletions
@@ -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);
/// <summary>Only the articles array, compact: the form the model sees and answers in.</summary>
public static string WriteArticlesCompact(ArticlePlan p) =>
JsonSerializer.Serialize(Shape(p), CompactOptions);
private static IEnumerable<object> 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,
});
/// <summary>
/// The plan in <paramref name="json"/>, 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<ArticleCategory>(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<ArticleCategory>(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"])));
}
@@ -24,7 +24,7 @@ public sealed record PlanAttempt(string NoteSetFingerprint, DateTimeOffset At);
/// </summary>
public sealed record EncyclopediaState(IReadOnlyDictionary<string, SubjectAttempt> Subjects, PlanAttempt? FailedPlan)
{
public static EncyclopediaState Empty { get; } = new(new Dictionary<string, SubjectAttempt>(), null);
public static EncyclopediaState Empty => new(new Dictionary<string, SubjectAttempt>(), null);
public static string NoteSetFingerprint(IReadOnlyList<Document> 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<EncyclopediaState>(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)
{
@@ -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();
}
/// <summary>Keeps a field on its line and inside its column.</summary>
private static string Flatten(string s) => s.Replace('\r', ' ').Replace('\n', ' ').Replace('|', ' ');
}
+2 -2
View File
@@ -6,7 +6,7 @@ namespace Wiki.Application.Encyclopedia;
/// </summary>
public static class Prompts
{
public const int Version = 1;
public const int Version = 2;
/// <summary>Placeholder in <see cref="Plan"/> replaced by <c>MaxSourceCharsPerArticle</c>.</summary>
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"]}]}
""";
}
@@ -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<string, SubjectAttempt>)EncyclopediaState.Empty.Subjects)["x"] = new("f", AttemptOutcome.Written, At);
Assert.Empty(EncyclopediaState.Empty.Subjects);
}
[Fact]
public void SubjectFingerprint_ChangesWithSourceHashOrTitle()
{
@@ -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()
{