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]