diff --git a/src/Wiki.Application/Encyclopedia/EncyclopediaReconciler.cs b/src/Wiki.Application/Encyclopedia/EncyclopediaReconciler.cs index ef0c25f..219b4af 100644 --- a/src/Wiki.Application/Encyclopedia/EncyclopediaReconciler.cs +++ b/src/Wiki.Application/Encyclopedia/EncyclopediaReconciler.cs @@ -85,7 +85,7 @@ public sealed class EncyclopediaReconciler( if (plan is null) { log.LogWarning("No usable article plan; no article is written or deleted"); - return await FinishAsync(changes, new EncyclopediaState(subjects, failedPlan), stateText, + return await FinishAsync(changes, new EncyclopediaState(subjects, failedPlan, state.FailedBatches, state.KeptArticles), stateText, new ReconcileSummary(0, 0, 0, 0, 0, false, "no-plan", null), ct); } @@ -162,7 +162,7 @@ public sealed class EncyclopediaReconciler( } } - var summary = await FinishAsync(changes, new EncyclopediaState(subjects, failedPlan), stateText, + var summary = await FinishAsync(changes, new EncyclopediaState(subjects, failedPlan, state.FailedBatches, state.KeptArticles), stateText, new ReconcileSummary(plan.Articles.Count, written, deleted, rejected, remembered, stopped, null, null), ct); failure?.Throw(); return summary; diff --git a/src/Wiki.Application/Encyclopedia/EncyclopediaState.cs b/src/Wiki.Application/Encyclopedia/EncyclopediaState.cs index 4705da7..7c759cc 100644 --- a/src/Wiki.Application/Encyclopedia/EncyclopediaState.cs +++ b/src/Wiki.Application/Encyclopedia/EncyclopediaState.cs @@ -22,9 +22,21 @@ public sealed record PlanAttempt(string NoteSetFingerprint, DateTimeOffset At); /// What the nightly job remembers between runs (articles/_state.json), so it does not pay again /// for the same failure. An attempt is repeated only when its fingerprint changes. /// -public sealed record EncyclopediaState(IReadOnlyDictionary Subjects, PlanAttempt? FailedPlan) +/// +/// FailedBatches: fingerprint of an assignment batch whose reply was unusable → when. +/// KeptArticles: article slug → at which the fold call answered keep. +/// +public sealed record EncyclopediaState( + IReadOnlyDictionary Subjects, + PlanAttempt? FailedPlan, + IReadOnlyDictionary FailedBatches, + IReadOnlyDictionary KeptArticles) { - public static EncyclopediaState Empty => new(new Dictionary(), null); + public static EncyclopediaState Empty => + new(new Dictionary(), null, new Dictionary(), new Dictionary()); + + public static string BatchFingerprint(IReadOnlyList notes) => + Hash(Prompts.Version.ToString(), notes.Select(n => $"{n.Slug}@{n.ContentHash}").Order(StringComparer.Ordinal)); public static string NoteSetFingerprint(IReadOnlyList notes) => Hash(Prompts.Version.ToString(), notes.Select(n => n.Slug).Order(StringComparer.Ordinal)); @@ -35,7 +47,7 @@ public sealed record EncyclopediaState(IReadOnlyDictionary $"{s}@{hashes.GetValueOrDefault(s, "")}") .Order(StringComparer.Ordinal); - return Hash([Prompts.Version.ToString(), planned.TitleDe, planned.TitleEn, planned.Category.ToString(), .. sources]); + return Hash([Prompts.Version.ToString(), planned.TitleDe, planned.TitleEn, planned.Category.ToString(), planned.Scope, .. sources]); } private static string Hash(string first, IEnumerable rest) => Hash([first, .. rest]); @@ -62,7 +74,9 @@ public static class EncyclopediaStateJson var s = JsonSerializer.Deserialize(json, Options); 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); + var failedBatches = (s.FailedBatches ?? new Dictionary()).ToDictionary(); + var kept = (s.KeptArticles ?? new Dictionary()).Where(kv => kv.Value is not null).ToDictionary(); + return new EncyclopediaState(subjects, s.FailedPlan, failedBatches, kept); } catch (JsonException) { diff --git a/test/Wiki.Application.Tests/EncyclopediaStateTests.cs b/test/Wiki.Application.Tests/EncyclopediaStateTests.cs index 5fe6c93..2a388db 100644 --- a/test/Wiki.Application.Tests/EncyclopediaStateTests.cs +++ b/test/Wiki.Application.Tests/EncyclopediaStateTests.cs @@ -16,7 +16,9 @@ public class EncyclopediaStateTests ["heating"] = new("abc", AttemptOutcome.Written, At), ["sauna"] = new("def", AttemptOutcome.Rejected, At), }, - new PlanAttempt("zzz", At)); + new PlanAttempt("zzz", At), + new Dictionary(), + new Dictionary()); var read = EncyclopediaStateJson.Read(EncyclopediaStateJson.Write(state)); @@ -24,6 +26,43 @@ public class EncyclopediaStateTests Assert.Equal(state.FailedPlan, read.FailedPlan); } + [Fact] + public void RoundTrip_FailedBatchesAndKept() + { + var state = new EncyclopediaState( + new Dictionary(), + null, + new Dictionary { ["fp1"] = At }, + new Dictionary { ["heating"] = "fp2" }); + + var read = EncyclopediaStateJson.Read(EncyclopediaStateJson.Write(state)); + + Assert.Equal(state.FailedBatches, read.FailedBatches); + Assert.Equal(state.KeptArticles, read.KeptArticles); + } + + [Fact] + public void Read_OldJsonWithoutNewFields_ReadsEmptyBatchesAndKept() + { + var read = EncyclopediaStateJson.Read("""{"subjects":{},"failedPlan":{"noteSetFingerprint":"x","at":"2026-10-24T03:00:00+00:00"}}"""); + Assert.NotNull(read.FailedPlan); + Assert.Empty(read.FailedBatches); + Assert.Empty(read.KeptArticles); + } + + [Fact] + public void BatchFingerprint_ChangesWhenANoteChanges() + { + var ab = EncyclopediaState.BatchFingerprint([PlanArticlesUseCaseTests.Note("a", hash: "1"), PlanArticlesUseCaseTests.Note("b", hash: "2")]); + var ba = EncyclopediaState.BatchFingerprint([PlanArticlesUseCaseTests.Note("b", hash: "2"), PlanArticlesUseCaseTests.Note("a", hash: "1")]); + var changed = EncyclopediaState.BatchFingerprint([PlanArticlesUseCaseTests.Note("a", hash: "1"), PlanArticlesUseCaseTests.Note("b", hash: "3")]); + var fewer = EncyclopediaState.BatchFingerprint([PlanArticlesUseCaseTests.Note("a", hash: "1")]); + Assert.Equal(ab, ba); + Assert.NotEqual(ab, changed); + Assert.NotEqual(ab, fewer); + Assert.Matches("^[0-9a-f]{64}$", ab); + } + [Fact] public void EncyclopediaStateJson_Malformed_ReadsEmpty() { @@ -59,6 +98,7 @@ public class EncyclopediaStateTests var changedHash = new[] { notes[0], PlanArticlesUseCaseTests.Note("b", hash: "3") }; Assert.NotEqual(baseline, EncyclopediaState.SubjectFingerprint(planned, changedHash)); Assert.NotEqual(baseline, EncyclopediaState.SubjectFingerprint(planned with { TitleDe = "Heizungsanlage" }, notes)); + Assert.NotEqual(baseline, EncyclopediaState.SubjectFingerprint(planned with { Scope = "Only boilers" }, notes)); } [Fact]