feat(encyclopedia): state v2 with per-batch and keep memory

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 09:07:19 +01:00
co-authored by Claude Opus 5.5
parent 996ff182c0
commit 13d5495899
3 changed files with 61 additions and 7 deletions
@@ -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;
@@ -22,9 +22,21 @@ public sealed record PlanAttempt(string NoteSetFingerprint, DateTimeOffset At);
/// What the nightly job remembers between runs (<c>articles/_state.json</c>), so it does not pay again
/// for the same failure. An attempt is repeated only when its fingerprint changes.
/// </summary>
public sealed record EncyclopediaState(IReadOnlyDictionary<string, SubjectAttempt> Subjects, PlanAttempt? FailedPlan)
/// <remarks>
/// <c>FailedBatches</c>: fingerprint of an assignment batch whose reply was unusable → when.
/// <c>KeptArticles</c>: article slug → <see cref="SubjectFingerprint"/> at which the fold call answered keep.
/// </remarks>
public sealed record EncyclopediaState(
IReadOnlyDictionary<string, SubjectAttempt> Subjects,
PlanAttempt? FailedPlan,
IReadOnlyDictionary<string, DateTimeOffset> FailedBatches,
IReadOnlyDictionary<string, string> KeptArticles)
{
public static EncyclopediaState Empty => new(new Dictionary<string, SubjectAttempt>(), null);
public static EncyclopediaState Empty =>
new(new Dictionary<string, SubjectAttempt>(), null, new Dictionary<string, DateTimeOffset>(), new Dictionary<string, string>());
public static string BatchFingerprint(IReadOnlyList<Document> notes) =>
Hash(Prompts.Version.ToString(), notes.Select(n => $"{n.Slug}@{n.ContentHash}").Order(StringComparer.Ordinal));
public static string NoteSetFingerprint(IReadOnlyList<Document> notes) =>
Hash(Prompts.Version.ToString(), notes.Select(n => n.Slug).Order(StringComparer.Ordinal));
@@ -35,7 +47,7 @@ public sealed record EncyclopediaState(IReadOnlyDictionary<string, SubjectAttemp
var sources = planned.Sources
.Select(s => $"{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<string> rest) => Hash([first, .. rest]);
@@ -62,7 +74,9 @@ public static class EncyclopediaStateJson
var s = JsonSerializer.Deserialize<EncyclopediaState>(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<string, DateTimeOffset>()).ToDictionary();
var kept = (s.KeptArticles ?? new Dictionary<string, string>()).Where(kv => kv.Value is not null).ToDictionary();
return new EncyclopediaState(subjects, s.FailedPlan, failedBatches, kept);
}
catch (JsonException)
{