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:
@@ -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('|', ' ');
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user