From af7ca24819be79d0ff3bbc39993423be6c976ad3 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Tue, 27 Oct 2026 08:11:52 +0100 Subject: [PATCH] fix(encyclopedia): a planning reply that repeats a property is unusable JsonObject builds its dictionary on first access and throws ArgumentException on a repeated key, which escaped reply validation and failed the whole run (assign reply with titleEn twice). Parsing now rejects duplicates up front, so the reply counts as unusable like any other malformed one. Co-Authored-By: Claude Opus 5.5 --- src/Wiki.Application/Encyclopedia/PlanParsing.cs | 4 ++-- .../AssignNotesUseCaseTests.cs | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Wiki.Application/Encyclopedia/PlanParsing.cs b/src/Wiki.Application/Encyclopedia/PlanParsing.cs index 34a2e8d..32e7333 100644 --- a/src/Wiki.Application/Encyclopedia/PlanParsing.cs +++ b/src/Wiki.Application/Encyclopedia/PlanParsing.cs @@ -26,12 +26,12 @@ internal static partial class PlanParsing return text is not null && text.All(char.IsAsciiLetter) && Enum.TryParse(text, ignoreCase: true, out category); } - /// The JSON object in a reply, which may be wrapped in a code fence; null when it is not one. + /// The JSON object in a reply, which may be wrapped in a code fence; null when it is not one or repeats a property. public static JsonObject? Object(string reply) { try { - return JsonNode.Parse(StripFence(reply)) as JsonObject; + return JsonNode.Parse(StripFence(reply), documentOptions: new JsonDocumentOptions { AllowDuplicateProperties = false }) as JsonObject; } catch (JsonException) { diff --git a/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs b/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs index 87da135..90b7ea8 100644 --- a/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs +++ b/test/Wiki.Application.Tests/AssignNotesUseCaseTests.cs @@ -170,6 +170,20 @@ public class AssignNotesUseCaseTests Assert.Empty(outcome.Assignments); } + [Fact] + public async Task Assign_DuplicatePropertyInReply_IsUnusable() + { + var model = new FakeTextModel().Reply(""" + {"new":[{"slug":"garden","titleDe":"G","titleEn":"G","titleEn":"H","category":"room","scope":"s"}], + "notes":[{"note":"a","articles":["garden"]}]} + """); + + var outcome = await UseCase(model).AssignAsync(Batch("a"), [], ArticlePlan.Empty(), CancellationToken.None); + + Assert.True(outcome.Unusable); + Assert.Empty(outcome.Assignments); + } + [Fact] public async Task Assign_PromptIsBounded() { -- 2.54.0