feat(encyclopedia): reconcile through bounded assign, split and fold

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 09:42:32 +01:00
co-authored by Claude Opus 5.5
parent 7a6cfc5124
commit b41bee9743
27 changed files with 916 additions and 787 deletions
@@ -1,6 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Wiki.Core.Model;
namespace Wiki.Application.Encyclopedia;
@@ -145,31 +144,26 @@ public sealed class ArticlePlan
private void Replace(PlannedArticle a) => _articles[_articles.FindIndex(x => x.Slug == a.Slug)] = a;
}
public static partial class ArticlePlanJson
public static class ArticlePlanJson
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { WriteIndented = true };
private static readonly JsonSerializerOptions CompactOptions = new(JsonSerializerDefaults.Web);
[GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*$")]
private static partial Regex SlugPattern();
/// <summary>Map keys in ordinal order, so an unchanged plan serializes identically.</summary>
public static string Write(ArticlePlan p) =>
JsonSerializer.Serialize(new { Articles = Shape(p), p.Assignments, p.Redirects }, 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
{
a.Slug,
a.TitleDe,
a.TitleEn,
Category = a.Category.ToString().ToLowerInvariant(),
a.Scope,
a.Sources,
});
JsonSerializer.Serialize(new
{
Articles = p.Articles.Select(a => new
{
a.Slug,
a.TitleDe,
a.TitleEn,
Category = a.Category.ToString().ToLowerInvariant(),
a.Scope,
a.Sources,
}),
Assignments = new SortedDictionary<string, IReadOnlyList<string>>(p.Assignments.ToDictionary(), StringComparer.Ordinal),
Redirects = new SortedDictionary<string, string>(p.Redirects.ToDictionary(), StringComparer.Ordinal),
}, Options);
/// <summary>
/// The plan in <paramref name="json"/>, which may be wrapped in a code fence; null when it is not a plan,
@@ -180,7 +174,7 @@ public static partial class ArticlePlanJson
{
try
{
if (JsonNode.Parse(StripFence(json)) is not JsonObject root || root["articles"] is not JsonArray items
if (JsonNode.Parse(PlanParsing.StripFence(json)) is not JsonObject root || root["articles"] is not JsonArray items
|| root["assignments"] is not JsonObject assignments) return null;
var plan = ArticlePlan.Empty();
foreach (var a in Articles(items))
@@ -188,13 +182,13 @@ public static partial class ArticlePlanJson
if (root["redirects"] is JsonObject redirects)
foreach (var (from, to) in redirects)
{
var target = Text(to);
if (target is not null && SlugPattern().IsMatch(from) && plan.Find(from) is null) plan.SetRedirect(from, target);
var target = PlanParsing.Text(to);
if (target is not null && PlanParsing.SlugPattern().IsMatch(from) && plan.Find(from) is null) plan.SetRedirect(from, target);
}
foreach (var (note, value) in assignments)
{
if (value is not JsonArray slugs) continue;
var all = Strings(slugs);
var all = PlanParsing.Strings(slugs);
var known = all.Where(s => plan.Find(s) is not null).Distinct(StringComparer.Ordinal).ToList();
if (all.Count == 0 || known.Count > 0) plan.SetAssignment(note, known);
}
@@ -206,48 +200,18 @@ public static partial class ArticlePlanJson
}
}
/// <summary>The articles array of a model reply in the one-shot format (no <c>assignments</c>); null when it is not JSON with one. Never throws.</summary>
internal static IReadOnlyList<PlannedArticle>? ReadArticles(string json)
{
try
{
return JsonNode.Parse(StripFence(json)) is JsonObject root && root["articles"] is JsonArray items ? Articles(items) : null;
}
catch (Exception e) when (e is JsonException or InvalidOperationException)
{
return null;
}
}
private static List<PlannedArticle> Articles(JsonArray items)
{
var articles = new List<PlannedArticle>();
foreach (var item in items)
{
if (item is not JsonObject o) continue;
var slug = Text(o["slug"]);
var categoryText = Text(o["category"]);
if (slug is null || !SlugPattern().IsMatch(slug) || 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,
Text(o["scope"]) ?? "", Strings(o["sources"])));
var slug = PlanParsing.Text(o["slug"]);
if (slug is null || !PlanParsing.SlugPattern().IsMatch(slug)
|| !PlanParsing.TryCategory(PlanParsing.Text(o["category"]), out var category)) continue;
articles.Add(new PlannedArticle(slug, PlanParsing.Text(o["titleDe"]) ?? slug, PlanParsing.Text(o["titleEn"]) ?? slug, category,
PlanParsing.Text(o["scope"]) ?? "", PlanParsing.Strings(o["sources"])));
}
return articles;
}
/// <summary>Trimmed, as frontmatter reads it back; null when blank.</summary>
private static string? Text(JsonNode? n) =>
n is JsonValue v && v.TryGetValue<string>(out var s) && !string.IsNullOrWhiteSpace(s) ? s.Trim() : null;
private static List<string> Strings(JsonNode? n) =>
n is JsonArray a ? a.Select(Text).OfType<string>().ToList() : [];
internal static string StripFence(string text)
{
var t = text.Trim();
if (!t.StartsWith("```")) return t;
var start = t.IndexOf('\n');
var end = t.LastIndexOf("```", StringComparison.Ordinal);
return start >= 0 && end > start ? t[(start + 1)..end].Trim() : t;
}
}
@@ -1,11 +1,10 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Wiki.Core.Model;
using Wiki.Core.Ports;
using static Wiki.Application.Encyclopedia.PlanParsing;
namespace Wiki.Application.Encyclopedia;
@@ -20,13 +19,10 @@ public sealed record AssignOutcome(
bool Unusable);
/// <summary>Assigns one batch of notes to nearby existing articles or to new ones, in one bounded model call.</summary>
public sealed partial class AssignNotesUseCase(ITextModel model, EncyclopediaSettings settings, ILogger<AssignNotesUseCase> log)
public sealed class AssignNotesUseCase(ITextModel model, EncyclopediaSettings settings, ILogger<AssignNotesUseCase> log)
{
private const int FirstParagraphChars = 300;
[GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*$")]
private static partial Regex SlugPattern();
private static AssignOutcome Unusable() =>
new([], new Dictionary<string, IReadOnlyList<string>>(), true);
@@ -52,15 +48,7 @@ public sealed partial class AssignNotesUseCase(ITextModel model, EncyclopediaSet
return Unusable();
}
JsonObject? root;
try
{
root = JsonNode.Parse(ArticlePlanJson.StripFence(reply.Text)) as JsonObject;
}
catch (JsonException)
{
root = null;
}
var root = PlanParsing.Object(reply.Text);
if (root is null)
{
log.LogWarning("Assign reply is not a JSON object");
@@ -137,9 +125,7 @@ public sealed partial class AssignNotesUseCase(ITextModel model, EncyclopediaSet
log.LogWarning("Dropping new article {Slug}: slug taken or duplicate", slug);
return null;
}
var categoryText = Text(o["category"]);
if (categoryText is null || !categoryText.All(char.IsAsciiLetter)
|| !Enum.TryParse<ArticleCategory>(categoryText, ignoreCase: true, out var category))
if (!TryCategory(Text(o["category"]), out var category))
{
log.LogWarning("Dropping new article {Slug}: unknown category", slug);
return null;
@@ -158,12 +144,6 @@ public sealed partial class AssignNotesUseCase(ITextModel model, EncyclopediaSet
return new PlannedArticle(slug, titleDe, titleEn, category, scope, []);
}
private static string? Text(JsonNode? n) =>
n is JsonValue v && v.TryGetValue<string>(out var s) && !string.IsNullOrWhiteSpace(s) ? s.Trim() : null;
private static List<string> Strings(JsonNode? n) =>
n is JsonArray a ? a.Select(Text).OfType<string>().ToList() : [];
private static string UserPrompt(IReadOnlyList<Document> batch, List<PlannedArticle> candidates)
{
var sb = new StringBuilder();
@@ -187,7 +167,4 @@ public sealed partial class AssignNotesUseCase(ITextModel model, EncyclopediaSet
var text = Flatten(string.Join(' ', lines)).Trim();
return text.Length > FirstParagraphChars ? text[..FirstParagraphChars] : text;
}
/// <summary>Keeps a field on its line and inside its column.</summary>
private static string Flatten(string s) => s.Replace('\r', ' ').Replace('\n', ' ').Replace('|', ' ');
}
@@ -7,11 +7,13 @@ using Wiki.Core.Ports;
namespace Wiki.Application.Encyclopedia;
/// <summary>
/// What one run did. Counts are subjects, each in both languages. <paramref name="Remembered"/> counts subjects
/// rejected earlier whose inputs are unchanged, so they were not retried. <paramref name="Commit"/> is null when nothing was committed.
/// What one run did. <paramref name="Planned"/> is the article count after the run; <paramref name="Assigned"/> counts notes,
/// <paramref name="Split"/> and <paramref name="Folded"/> articles; the write counts are subjects, each in both languages.
/// <paramref name="Remembered"/> counts subjects rejected earlier whose inputs are unchanged, so they were not retried.
/// <paramref name="Commit"/> is null when nothing was committed.
/// </summary>
public sealed record ReconcileSummary(int Planned, int Written, int Deleted, int Rejected, int Remembered,
bool StoppedByModel, string? Skipped, string? Commit);
public sealed record ReconcileSummary(int Planned, int Assigned, int Split, int Folded, int Written, int Deleted, int Rejected,
int Remembered, bool StoppedByModel, string? Skipped, string? Commit);
public interface IEncyclopediaRun
{
@@ -19,14 +21,18 @@ public interface IEncyclopediaRun
}
/// <summary>
/// Brings <c>articles/</c> up to date with the notes: replans when the note set changed, rewrites the articles whose
/// inputs changed, deletes the subjects a new plan dropped, and commits everything as one commit.
/// Brings <c>articles/</c> up to date with the notes: updates the stored plan in bounded steps (drop removed notes, assign new
/// ones, split oversized and fold thin articles), rewrites the articles whose inputs changed, deletes the files of articles
/// the plan dropped, and commits everything as one commit.
/// Works only on a catalog that reflects the store's head, so what it compares is what it commits onto.
/// </summary>
public sealed class EncyclopediaReconciler(
INoteStore store,
IDocumentCatalog catalog,
PlanArticlesUseCase planner,
ArticleNeighbours neighbours,
AssignNotesUseCase assigner,
SplitArticleUseCase splitter,
FoldArticleUseCase folder,
IArticleWriter writer,
EncyclopediaSettings settings,
TimeProvider clock,
@@ -36,7 +42,27 @@ public sealed class EncyclopediaReconciler(
private const string StatePath = "articles/_state.json";
private static readonly string[] Languages = ["de", "en"];
/// <summary>An unexpected failure while writing is rethrown after what is staged is committed; caller cancellation commits nothing.</summary>
/// <summary>The inputs and mutable outputs of one run.</summary>
private sealed class Run(IReadOnlyList<Document> notes, ArticlePlan plan, EncyclopediaState state, DateTimeOffset now)
{
public IReadOnlyList<Document> Notes { get; } = notes;
public Dictionary<string, Document> NoteBySlug { get; } =
notes.GroupBy(n => n.Slug, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
public ArticlePlan Plan { get; } = plan;
public DateTimeOffset Now { get; } = now;
public SortedDictionary<string, SubjectAttempt> Subjects { get; } = new(state.Subjects.ToDictionary(), StringComparer.Ordinal);
public SortedDictionary<string, DateTimeOffset> FailedBatches { get; } = new(state.FailedBatches.ToDictionary(), StringComparer.Ordinal);
public SortedDictionary<string, string> Kept { get; } = new(state.KeptArticles.ToDictionary(), StringComparer.Ordinal);
/// <summary>Children of the articles split this run: their overview's extra link targets.</summary>
public Dictionary<string, IReadOnlyList<PlannedArticle>> Children { get; } = new(StringComparer.Ordinal);
public int Assigned { get; set; }
public int Split { get; set; }
public int Folded { get; set; }
public EncyclopediaState State => new(Subjects, FailedBatches, Kept);
}
/// <summary>An unexpected failure is rethrown after what is staged is committed; caller cancellation commits nothing.</summary>
public async Task<ReconcileSummary> RunAsync(CancellationToken ct)
{
var head = (await store.GetHeadAsync(ct)).Commit;
@@ -44,79 +70,236 @@ public sealed class EncyclopediaReconciler(
if (synced != head)
{
log.LogWarning("Catalog is at {Catalog}, the store at {Head}; skipping the encyclopedia run", synced, head);
return new ReconcileSummary(0, 0, 0, 0, 0, false, "catalog-behind", null);
return new ReconcileSummary(0, 0, 0, 0, 0, 0, 0, 0, false, "catalog-behind", null);
}
var notes = await catalog.ListDocumentsAsync(DocumentKind.Wiki, ct);
var articleDocs = await catalog.ListDocumentsAsync(DocumentKind.Article, ct);
var planText = await store.ReadTextAsync(PlanPath, head, ct);
var stateText = await store.ReadTextAsync(StatePath, head, ct);
var current = ReadPlan(planText);
var state = ReadState(stateText);
var subjects = new SortedDictionary<string, SubjectAttempt>(state.Subjects.ToDictionary(), StringComparer.Ordinal);
var failedPlan = state.FailedPlan;
var now = clock.GetUtcNow();
var run = new Run(notes, ReadPlan(planText), ReadState(stateText), clock.GetUtcNow());
var plan = run.Plan;
var planned = plan.Articles.Select(a => a.Slug).ToList();
var changes = new List<FileChange>();
ArticlePlan? replanned = null;
if (PlanArticlesUseCase.NeedsReplan(current, failedPlan, notes))
{
try
{
replanned = await planner.PlanAsync(current, notes, ct);
}
catch (ModelUnavailableException e)
{
log.LogWarning(e, "Text model unavailable while planning; keeping the current plan");
return new ReconcileSummary(current?.Articles.Count ?? 0, 0, 0, 0, 0, true, null, null);
}
if (replanned is null)
{
failedPlan = new PlanAttempt(EncyclopediaState.NoteSetFingerprint(notes), now);
}
else
{
failedPlan = null;
changes.Add(Put(PlanPath, ArticlePlanJson.Write(replanned), planText?.BlobSha));
}
}
foreach (var note in plan.Assignments.Keys.Where(n => !run.NoteBySlug.ContainsKey(n)).Order(StringComparer.Ordinal).ToList())
foreach (var slug in plan.RemoveNote(note))
log.LogInformation("Article {Slug} lost its last source {Note}; deleting it", slug, note);
var plan = replanned ?? current;
if (plan is null)
var stopped = false;
ExceptionDispatchInfo? failure = null;
try
{
log.LogWarning("No usable article plan; no article is written or deleted");
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);
await PlanStepsAsync(run, ct);
}
catch (ModelUnavailableException e)
{
log.LogWarning(e, "Text model unavailable while planning; committing the plan so far, writing nothing");
stopped = true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception e)
{
log.LogError(e, "Planning failed unexpectedly; committing the plan so far, then failing the run");
failure = ExceptionDispatchInfo.Capture(e);
}
var hashByPath = articleDocs.ToDictionary(d => d.Path, d => d.ContentHash, StringComparer.Ordinal);
var stale = Stale(plan, notes, ParseArticles(articleDocs));
var remembered = InScope(stale, plan, settings).Count(p => IsRemembered(p, notes, state));
var due = Due(stale, plan, notes, state, settings);
var noteBySlug = notes.GroupBy(n => n.Slug, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var stale = Stale(plan, notes, ParseArticles(articleDocs), settings);
var remembered = InScope(stale, plan, settings).Count(p => IsRemembered(p, notes, run.State));
int written = 0, rejected = 0;
var stopped = false;
ExceptionDispatchInfo? failure = null;
if (!stopped && failure is null)
(written, rejected, stopped, failure) = await WriteAsync(run, Due(stale, plan, notes, run.State, settings), hashByPath, changes, ct);
var deleted = 0;
foreach (var slug in planned.Where(s => plan.Find(s) is null))
{
var any = false;
foreach (var lang in Languages)
{
var path = ArticlePath(lang, slug);
if (!hashByPath.TryGetValue(path, out var hash)) continue;
changes.Add(new FileChange(FileChangeOp.Delete, path, null, hash));
any = true;
}
run.Subjects.Remove(slug);
run.Kept.Remove(slug);
if (any) deleted++;
}
var planJson = ArticlePlanJson.Write(plan);
if (planJson != (planText?.Text ?? ArticlePlanJson.Write(ArticlePlan.Empty())))
changes.Add(Put(PlanPath, planJson, planText?.BlobSha));
var summary = await FinishAsync(changes, run.State, stateText,
new ReconcileSummary(plan.Articles.Count, run.Assigned, run.Split, run.Folded, written, deleted, rejected, remembered,
stopped, null, null), ct);
failure?.Throw();
return summary;
}
/// <summary>
/// Assign, split, fold, each within its cap. An unreachable embedding or index service (<see cref="HttpRequestException"/>)
/// ends the planning steps; what they changed stays.
/// </summary>
private async Task PlanStepsAsync(Run run, CancellationToken ct)
{
try
{
await AssignAsync(run, ct);
await SplitAsync(run, ct);
await FoldAsync(run, ct);
}
catch (HttpRequestException e)
{
log.LogWarning(e, "Planning stopped: a service did not answer; writing with the plan so far");
}
}
/// <summary>
/// Batches of similar new notes, seeded in slug order, each assigned in one call against the articles near its notes in the
/// plan as it stands, so later batches see the articles earlier ones created. A batch whose reply was unusable before, for
/// the same notes, is skipped without a call; a batch is tried once per run.
/// </summary>
private async Task AssignAsync(Run run, CancellationToken ct)
{
var plan = run.Plan;
var fresh = run.NoteBySlug.Values.Where(n => !plan.IsAssigned(n.Slug)).OrderBy(n => n.Slug, StringComparer.Ordinal).ToList();
var tried = new HashSet<string>(StringComparer.Ordinal);
var calls = 0;
while (calls < settings.MaxAssignBatchesPerRun)
{
var open = fresh.Where(n => !tried.Contains(n.Slug) && !plan.IsAssigned(n.Slug)).ToList();
if (open.Count == 0) return;
var batch = await neighbours.NearestAsync(open[0], open.Select(n => n.Slug).ToHashSet(StringComparer.Ordinal),
settings.AssignBatchSize, run.NoteBySlug, ct);
tried.UnionWith(batch.Select(n => n.Slug));
var fingerprint = EncyclopediaState.BatchFingerprint(batch);
if (run.FailedBatches.ContainsKey(fingerprint)) continue;
var nearby = new List<string>();
foreach (var note in batch)
{
foreach (var slug in await neighbours.NearbyArticlesAsync(note, plan, ct))
if (nearby.Count < settings.CandidateArticles * 2 && !nearby.Contains(slug)) nearby.Add(slug);
if (nearby.Count >= settings.CandidateArticles * 2) break;
}
calls++;
var outcome = await assigner.AssignAsync(batch, Articles(plan, nearby), plan, ct);
if (outcome.Unusable)
{
run.FailedBatches[fingerprint] = run.Now;
continue;
}
foreach (var a in outcome.New) plan.AddArticle(a);
foreach (var (note, articles) in outcome.Assignments) plan.Assign(note, articles);
run.Assigned += outcome.Assignments.Count;
}
if (fresh.Any(n => !tried.Contains(n.Slug) && !plan.IsAssigned(n.Slug)))
log.LogInformation("Assign cap of {Cap} batches reached; the other new notes wait for the next run", settings.MaxAssignBatchesPerRun);
}
/// <summary>Oversized articles, biggest first, at most <see cref="EncyclopediaSettings.MaxSplitsPerRun"/> calls.</summary>
private async Task SplitAsync(Run run, CancellationToken ct)
{
var plan = run.Plan;
var oversized = plan.Articles
.Where(a => SplitArticleUseCase.NeedsSplit(a, run.NoteBySlug, settings))
.OrderByDescending(a => a.Sources.Sum(s => run.NoteBySlug.TryGetValue(s, out var d) ? (long)d.Body.Length : 0))
.ThenByDescending(a => a.Sources.Count)
.ThenBy(a => a.Slug, StringComparer.Ordinal)
.Take(settings.MaxSplitsPerRun)
.ToList();
foreach (var article in oversized)
{
var outcome = await splitter.SplitAsync(article, run.NoteBySlug, plan, ct);
if (outcome is null) continue;
// children first, so a note that moves wholly to a child never goes unassigned
foreach (var child in outcome.Children)
{
plan.AddArticle(child with { Sources = [] });
plan.ReplaceArticle(child);
}
plan.ReplaceArticle(outcome.Overview);
run.Children[article.Slug] = outcome.Children;
run.Split++;
}
}
/// <summary>
/// Thin articles with a nearby article, not kept before at the same inputs, at most <see cref="EncyclopediaSettings.MaxFoldsPerRun"/>
/// calls. An answer other than a usable fold is remembered like a keep, so the article is not asked again until its inputs change.
/// </summary>
private async Task FoldAsync(Run run, CancellationToken ct)
{
var plan = run.Plan;
var thin = plan.Articles.Where(a => FoldArticleUseCase.IsThin(a, run.NoteBySlug, settings)).Select(a => a.Slug).ToList();
var calls = 0;
foreach (var slug in thin)
{
if (calls >= settings.MaxFoldsPerRun) return;
if (plan.Find(slug) is not { } article || !FoldArticleUseCase.IsThin(article, run.NoteBySlug, settings)) continue;
var fingerprint = EncyclopediaState.SubjectFingerprint(article, run.Notes);
if (run.Kept.GetValueOrDefault(slug) == fingerprint) continue;
var nearby = Articles(plan, await neighbours.NearbyArticlesAsync(article, run.NoteBySlug, plan, ct));
if (nearby.Count == 0) continue;
calls++;
var into = await folder.FoldAsync(article, run.NoteBySlug, nearby, ct);
if (into is null)
{
run.Kept[slug] = fingerprint;
continue;
}
plan.Fold(slug, into);
run.Folded++;
}
}
private async Task<(int Written, int Rejected, bool Stopped, ExceptionDispatchInfo? Failure)> WriteAsync(
Run run, IReadOnlyList<PlannedArticle> due, Dictionary<string, string> hashByPath, List<FileChange> changes, CancellationToken ct)
{
int written = 0, rejected = 0;
var neighboursUp = true;
foreach (var planned in due)
{
var fingerprint = EncyclopediaState.SubjectFingerprint(planned, notes);
var sources = planned.Sources.Where(noteBySlug.ContainsKey).Select(s => noteBySlug[s]).ToList();
var fingerprint = EncyclopediaState.SubjectFingerprint(planned, run.Notes);
var sources = WriteSources(planned, run.NoteBySlug, settings);
try
{
var article = await writer.WriteAsync(planned, sources, plan.Articles, ct);
IReadOnlyList<PlannedArticle> targets = [];
if (neighboursUp)
{
try
{
targets = Articles(run.Plan, await neighbours.NearbyArticlesAsync(planned, run.NoteBySlug, run.Plan, ct));
}
catch (HttpRequestException e)
{
log.LogWarning(e, "Nearby articles unavailable; writing without link targets");
neighboursUp = false;
}
}
if (run.Children.TryGetValue(planned.Slug, out var children))
targets = Articles(run.Plan, [.. targets.Select(t => t.Slug), .. children.Select(c => c.Slug)]);
var article = await writer.WriteAsync(planned, sources, targets, ct);
foreach (var a in new[] { article.De, article.En })
{
var path = ArticlePath(a.Lang, a.Slug);
changes.Add(Put(path, ArticleFile.Build(a), hashByPath.GetValueOrDefault(path)));
}
subjects[planned.Slug] = new SubjectAttempt(fingerprint, AttemptOutcome.Written, now);
run.Subjects[planned.Slug] = new SubjectAttempt(fingerprint, AttemptOutcome.Written, run.Now);
written++;
}
catch (ArticleRejectedException e)
{
log.LogWarning("Article {Slug} rejected: {Reason}; kept as it was, retried when its inputs change", e.Slug, e.Reason);
subjects[planned.Slug] = new SubjectAttempt(fingerprint, AttemptOutcome.Rejected, now);
run.Subjects[planned.Slug] = new SubjectAttempt(fingerprint, AttemptOutcome.Rejected, run.Now);
rejected++;
}
catch (HttpRequestException e)
@@ -127,8 +310,7 @@ public sealed class EncyclopediaReconciler(
catch (ModelUnavailableException e)
{
log.LogWarning(e, "Text model unavailable; stopping after {Written} articles", written);
stopped = true;
break;
return (written, rejected, true, null);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -137,48 +319,29 @@ public sealed class EncyclopediaReconciler(
catch (Exception e)
{
log.LogError(e, "Article {Slug} failed unexpectedly; committing what is done, then failing the run", planned.Slug);
failure = ExceptionDispatchInfo.Capture(e);
break;
return (written, rejected, false, ExceptionDispatchInfo.Capture(e));
}
}
// Deletes travel with the plan that drops the subjects: once that plan is committed, the difference is gone.
var deleted = 0;
if (replanned is not null && current is not null)
{
var kept = replanned.Articles.Select(a => a.Slug).ToHashSet(StringComparer.Ordinal);
foreach (var slug in current.Articles.Select(a => a.Slug).Where(s => !kept.Contains(s)).Distinct(StringComparer.Ordinal))
{
var any = false;
foreach (var lang in Languages)
{
var path = ArticlePath(lang, slug);
if (!hashByPath.TryGetValue(path, out var hash)) continue;
changes.Add(new FileChange(FileChangeOp.Delete, path, null, hash));
any = true;
}
subjects.Remove(slug);
if (any) deleted++;
}
}
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;
return (written, rejected, false, null);
}
/// <summary>
/// Planned articles, in plan order, whose files no longer match the plan and the notes: missing in either language,
/// a different set of sources than the plan's sources that exist, a source hash other than the catalog's, or another title.
/// a different set of sources than the plan's sources the writer gets (<see cref="WriteSources"/>), a source hash other
/// than the catalog's, or another title.
/// </summary>
public static IReadOnlyList<PlannedArticle> Stale(ArticlePlan plan, IReadOnlyList<Document> notes, IReadOnlyList<Article> existing)
public static IReadOnlyList<PlannedArticle> Stale(ArticlePlan plan, IReadOnlyList<Document> notes, IReadOnlyList<Article> existing,
EncyclopediaSettings settings)
{
var hashes = notes.GroupBy(n => n.Slug, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First().ContentHash, StringComparer.Ordinal);
var bySlug = notes.GroupBy(n => n.Slug, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var articles = existing.GroupBy(a => (a.Lang, a.Slug)).ToDictionary(g => g.Key, g => g.First());
return plan.Articles
.Where(p => IsStale(p, hashes, articles.GetValueOrDefault(("de", p.Slug)), p.TitleDe)
|| IsStale(p, hashes, articles.GetValueOrDefault(("en", p.Slug)), p.TitleEn))
.Where(p =>
{
var sources = WriteSources(p, bySlug, settings);
return IsStale(sources, articles.GetValueOrDefault(("de", p.Slug)), p.TitleDe)
|| IsStale(sources, articles.GetValueOrDefault(("en", p.Slug)), p.TitleEn);
})
.ToList();
}
@@ -194,6 +357,24 @@ public sealed class EncyclopediaReconciler(
.Take(settings.MaxArticlesPerRun)
.ToList();
/// <summary>
/// The sources that exist, most relevant first, up to <see cref="EncyclopediaSettings.MaxSourceCharsPerArticle"/> of body
/// (at least one): an article waiting for its split is written from its first sources.
/// </summary>
private static List<Document> WriteSources(PlannedArticle planned, IReadOnlyDictionary<string, Document> notes, EncyclopediaSettings settings)
{
var kept = new List<Document>();
long total = 0;
foreach (var slug in planned.Sources)
{
if (!notes.TryGetValue(slug, out var note)) continue;
total += note.Body.Length;
if (kept.Count > 0 && total > settings.MaxSourceCharsPerArticle) break;
kept.Add(note);
}
return kept;
}
private static IEnumerable<PlannedArticle> InScope(IReadOnlyList<PlannedArticle> stale, ArticlePlan plan, EncyclopediaSettings settings)
{
if (settings.PilotSlugs.Count > 0)
@@ -214,14 +395,18 @@ public sealed class EncyclopediaReconciler(
&& attempt.Outcome == AttemptOutcome.Rejected
&& attempt.Fingerprint == EncyclopediaState.SubjectFingerprint(planned, notes);
private static bool IsStale(PlannedArticle planned, Dictionary<string, string> hashes, Article? article, string title)
private static bool IsStale(List<Document> sources, Article? article, string title)
{
if (article is null || article.Title != title) return true;
var sources = planned.Sources.Where(hashes.ContainsKey).ToHashSet(StringComparer.Ordinal);
return !article.Sources.Select(s => s.Slug).ToHashSet(StringComparer.Ordinal).SetEquals(sources)
var hashes = sources.ToDictionary(d => d.Slug, d => d.ContentHash, StringComparer.Ordinal);
return !article.Sources.Select(s => s.Slug).ToHashSet(StringComparer.Ordinal).SetEquals(hashes.Keys)
|| article.Sources.Any(s => hashes[s.Slug] != s.ContentHash);
}
/// <summary>The live articles <paramref name="slugs"/> stand for, in order, each once.</summary>
private static List<PlannedArticle> Articles(ArticlePlan plan, IEnumerable<string> slugs) =>
slugs.Select(plan.Resolve).OfType<string>().Distinct(StringComparer.Ordinal).Select(s => plan.Find(s)!).ToList();
private async Task<ReconcileSummary> FinishAsync(List<FileChange> changes, EncyclopediaState state, StoredText? stateText,
ReconcileSummary summary, CancellationToken ct)
{
@@ -234,7 +419,8 @@ public sealed class EncyclopediaReconciler(
return summary;
}
var message = $"articles: {summary.Written} written, {summary.Deleted} deleted, {summary.Rejected} rejected";
var message = $"articles: {summary.Assigned} assigned, {summary.Split} split, {summary.Folded} folded, "
+ $"{summary.Written} written, {summary.Deleted} deleted, {summary.Rejected} rejected";
try
{
var commit = await store.CommitAsync(changes, message, ct);
@@ -265,19 +451,20 @@ public sealed class EncyclopediaReconciler(
return articles;
}
private ArticlePlan? ReadPlan(StoredText? text)
private ArticlePlan ReadPlan(StoredText? text)
{
if (text is null) return null;
if (text is null) return ArticlePlan.Empty();
var plan = ArticlePlanJson.Read(text.Text);
if (plan is null) log.LogWarning("{Path} is malformed; planning from scratch", PlanPath);
return plan;
if (plan is null) log.LogWarning("{Path} is malformed or in the old format; planning from scratch", PlanPath);
return plan ?? ArticlePlan.Empty();
}
private EncyclopediaState ReadState(StoredText? text)
{
if (text is null) return EncyclopediaState.Empty;
var state = EncyclopediaStateJson.Read(text.Text);
if (state.Subjects.Count == 0 && state.FailedPlan is null && text.Text.Trim() != EncyclopediaStateJson.Write(EncyclopediaState.Empty))
if (state.Subjects.Count == 0 && state.FailedBatches.Count == 0 && state.KeptArticles.Count == 0
&& text.Text.Trim() != EncyclopediaStateJson.Write(EncyclopediaState.Empty))
log.LogWarning("{Path} is malformed or empty; starting without attempt memory", StatePath);
return state;
}
@@ -0,0 +1,25 @@
namespace Wiki.Application.Encyclopedia;
/// <summary>
/// Limits of one encyclopedia run. <paramref name="PilotSlugs"/> and <paramref name="MaxArticles"/> limit what is written, not what is planned.
/// The <c>Max…PerRun</c> fields cap each step's model calls; the rest bound what one call sees.
/// </summary>
public sealed record EncyclopediaSettings(
int ArticleMaxTokens,
int MaxArticlesPerRun,
int MaxSourceCharsPerArticle,
IReadOnlyList<string> PilotSlugs,
int? MaxArticles,
int AssignBatchSize = 20,
int CandidateArticles = 10,
int NeighbourNotes = 30,
int NeighbourSources = 10,
int MaxAssignBatchesPerRun = 120,
int MaxSplitsPerRun = 5,
int MaxFoldsPerRun = 5,
int MaxSourcesPerArticle = 120,
int FoldMinSources = 3,
int FoldMinChars = 4000,
int AssignMaxTokens = 8000,
int SplitMaxTokens = 8000,
int FoldMaxTokens = 1000);
@@ -15,32 +15,26 @@ public enum AttemptOutcome
/// <summary>The last attempt at one subject: the inputs it was made from and how it ended.</summary>
public sealed record SubjectAttempt(string Fingerprint, AttemptOutcome Outcome, DateTimeOffset At);
/// <summary>A planning attempt that produced no usable plan, for the note set it was made with.</summary>
public sealed record PlanAttempt(string NoteSetFingerprint, DateTimeOffset At);
/// <summary>
/// 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>
/// <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.
/// <c>KeptArticles</c>: article slug → <see cref="SubjectFingerprint"/> at which the fold call did not fold it.
/// The <c>failedPlan</c> entry of earlier versions is ignored on read.
/// </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, new Dictionary<string, DateTimeOffset>(), new Dictionary<string, string>());
new(new Dictionary<string, SubjectAttempt>(), 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));
public static string SubjectFingerprint(PlannedArticle planned, IReadOnlyList<Document> notes)
{
var hashes = notes.GroupBy(n => n.Slug).ToDictionary(g => g.Key, g => g.First().ContentHash);
@@ -76,7 +70,7 @@ public static class EncyclopediaStateJson
var subjects = s.Subjects.Where(kv => kv.Value is not null).ToDictionary(kv => kv.Key, kv => kv.Value);
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);
return new EncyclopediaState(subjects, failedBatches, kept);
}
catch (JsonException)
{
@@ -1,10 +1,9 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using Wiki.Core.Model;
using Wiki.Core.Ports;
using static Wiki.Application.Encyclopedia.PlanParsing;
namespace Wiki.Application.Encyclopedia;
@@ -40,44 +39,32 @@ public sealed class FoldArticleUseCase(ITextModel model, EncyclopediaSettings se
return null;
}
JsonObject? root;
try
{
root = JsonNode.Parse(ArticlePlanJson.StripFence(reply.Text)) as JsonObject;
}
catch (JsonException)
{
root = null;
}
var root = PlanParsing.Object(reply.Text);
if (root is null)
{
log.LogWarning("Fold reply for {Slug} is not a JSON object", thin.Slug);
return null;
}
if (root["into"] is JsonValue v && v.TryGetValue<string>(out var into) && !string.IsNullOrWhiteSpace(into))
if (Text(root["into"]) is { } into)
{
into = into.Trim();
if (shown.Any(n => n.Slug == into)) return into;
log.LogWarning("Fold reply for {Slug} names {Target}, which is not a nearby article", thin.Slug, into);
}
return null;
}
private static string UserPrompt(PlannedArticle thin, IReadOnlyDictionary<string, Document> notes, List<PlannedArticle> nearby)
private string UserPrompt(PlannedArticle thin, IReadOnlyDictionary<string, Document> notes, List<PlannedArticle> nearby)
{
var sb = new StringBuilder();
sb.Append("Thin article: ").Append(thin.Slug).Append(" | ").Append(Flatten(thin.TitleDe)).Append(" | ")
.Append(Flatten(thin.TitleEn)).Append(" | ").Append(Flatten(thin.Scope)).Append('\n');
sb.Append("Its sources (title):\n");
foreach (var s in thin.Sources)
foreach (var s in thin.Sources.Take(settings.FoldMinSources))
sb.Append(notes.TryGetValue(s, out var d) ? Flatten(d.Title) : s).Append('\n');
sb.Append("\nNearby articles (slug | title | scope):\n");
foreach (var n in nearby)
sb.Append(n.Slug).Append(" | ").Append(Flatten(n.TitleDe)).Append(" | ").Append(Flatten(n.Scope)).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('|', ' ');
}
@@ -1,152 +0,0 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Wiki.Core.Model;
using Wiki.Core.Ports;
namespace Wiki.Application.Encyclopedia;
/// <summary>Limits of one encyclopedia run. <paramref name="PilotSlugs"/> and <paramref name="MaxArticles"/> limit what is written, not what is planned.</summary>
public sealed record EncyclopediaSettings(
int PlanMaxTokens,
int ArticleMaxTokens,
int MaxArticlesPerRun,
int MaxSourceCharsPerArticle,
IReadOnlyList<string> PilotSlugs,
int? MaxArticles)
{
public int AssignBatchSize { get; init; } = 20;
public int CandidateArticles { get; init; } = 10;
public int NeighbourNotes { get; init; } = 30;
public int NeighbourSources { get; init; } = 10;
public int MaxAssignBatchesPerRun { get; init; } = 120;
public int MaxSplitsPerRun { get; init; } = 5;
public int MaxFoldsPerRun { get; init; } = 5;
public int MaxSourcesPerArticle { get; init; } = 120;
public int FoldMinSources { get; init; } = 3;
public int FoldMinChars { get; init; } = 4000;
public int AssignMaxTokens { get; init; } = 8000;
public int SplitMaxTokens { get; init; } = 8000;
public int FoldMaxTokens { get; init; } = 1000;
}
/// <summary>
/// Asks the model which subjects get their own article and which notes feed each, and validates the answer.
/// A reply that cannot be used leaves the current plan in place.
/// </summary>
public sealed partial class PlanArticlesUseCase(ITextModel model, EncyclopediaSettings settings, ILogger<PlanArticlesUseCase> log)
{
[GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*$")]
private static partial Regex SlugPattern();
/// <summary>True when the notes changed since <paramref name="current"/> was planned, unless planning already failed for exactly this note set.</summary>
public static bool NeedsReplan(ArticlePlan? current, PlanAttempt? failedPlan, IReadOnlyList<Document> notes)
{
var changed = current is null
|| !current.Assignments.Keys.ToHashSet(StringComparer.Ordinal).SetEquals(notes.Select(n => n.Slug));
return changed && failedPlan?.NoteSetFingerprint != EncyclopediaState.NoteSetFingerprint(notes);
}
/// <summary>The new plan, or null to keep <paramref name="current"/>. Model-unavailable and other errors propagate.</summary>
public async Task<ArticlePlan?> PlanAsync(ArticlePlan? current, IReadOnlyList<Document> notes, CancellationToken ct)
{
TextReply reply;
try
{
reply = await model.CompleteAsync(
Prompts.Plan.Replace(Prompts.MaxSourceCharsToken, settings.MaxSourceCharsPerArticle.ToString()),
UserPrompt(current, notes), settings.PlanMaxTokens, ct);
}
catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.BadRequest)
{
log.LogWarning(e, "Planning request was rejected; keeping the current plan");
return null;
}
if (reply.Truncated)
{
log.LogWarning("Plan reply hit the token cap ({Tokens}); keeping the current plan", settings.PlanMaxTokens);
return null;
}
var parsed = ArticlePlanJson.ReadArticles(reply.Text);
if (parsed is null)
{
log.LogWarning("Plan reply is not valid JSON; keeping the current plan");
return null;
}
var articles = Validate(parsed, notes);
if (articles.Count == 0)
{
log.LogWarning("Plan reply has no valid article; keeping the current plan");
return null;
}
var plan = ArticlePlan.Empty();
foreach (var a in articles) plan.AddArticle(a);
foreach (var slug in notes.Select(n => n.Slug).Distinct(StringComparer.Ordinal))
plan.Assign(slug, articles.Where(a => a.Sources.Contains(slug)).Select(a => a.Slug).ToList());
return plan;
}
private List<PlannedArticle> Validate(IReadOnlyList<PlannedArticle> proposed, IReadOnlyList<Document> notes)
{
var lengths = notes.GroupBy(n => n.Slug).ToDictionary(g => g.Key, g => g.First().Body.Length);
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<PlannedArticle>();
foreach (var a in proposed)
{
if (!SlugPattern().IsMatch(a.Slug))
{
log.LogWarning("Dropping planned article with invalid slug {Slug}", a.Slug);
continue;
}
if (!seen.Add(a.Slug))
{
log.LogWarning("Dropping duplicate planned article {Slug}", a.Slug);
continue;
}
var sources = a.Sources.Where(lengths.ContainsKey).Distinct(StringComparer.Ordinal).ToList();
if (sources.Count == 0)
{
log.LogWarning("Dropping planned article {Slug}: no known source", a.Slug);
continue;
}
sources = CapSources(a.Slug, sources, lengths);
result.Add(a with { Sources = sources });
}
return result;
}
private List<string> CapSources(string slug, List<string> sources, Dictionary<string, int> lengths)
{
var kept = new List<string>();
long total = 0;
foreach (var s in sources)
{
total += lengths[s];
if (kept.Count > 0 && total > settings.MaxSourceCharsPerArticle) break;
kept.Add(s);
}
if (kept.Count < sources.Count)
log.LogWarning("Planned article {Slug} exceeds {Cap} source chars; dropped {Dropped} lowest-ranked sources",
slug, settings.MaxSourceCharsPerArticle, sources.Count - kept.Count);
return kept;
}
private static string UserPrompt(ArticlePlan? current, IReadOnlyList<Document> notes)
{
var sb = new StringBuilder();
if (current is not null)
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(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('|', ' ');
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Wiki.Core.Model;
namespace Wiki.Application.Encyclopedia;
/// <summary>Reading of plan files and planning replies, and the prompt-line form of their fields.</summary>
internal static partial class PlanParsing
{
[GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*$")]
public static partial Regex SlugPattern();
/// <summary>Trimmed, as frontmatter reads it back; null when blank or not a string.</summary>
public static string? Text(JsonNode? n) =>
n is JsonValue v && v.TryGetValue<string>(out var s) && !string.IsNullOrWhiteSpace(s) ? s.Trim() : null;
/// <summary>The non-blank strings of an array; empty when <paramref name="n"/> is not an array.</summary>
public static List<string> Strings(JsonNode? n) =>
n is JsonArray a ? a.Select(Text).OfType<string>().ToList() : [];
/// <summary>A category name in letters only, any case.</summary>
public static bool TryCategory(string? text, out ArticleCategory category)
{
category = default;
return text is not null && text.All(char.IsAsciiLetter) && Enum.TryParse(text, ignoreCase: true, out category);
}
/// <summary>The JSON object in a reply, which may be wrapped in a code fence; null when it is not one.</summary>
public static JsonObject? Object(string reply)
{
try
{
return JsonNode.Parse(StripFence(reply)) as JsonObject;
}
catch (JsonException)
{
return null;
}
}
public static string StripFence(string text)
{
var t = text.Trim();
if (!t.StartsWith("```")) return t;
var start = t.IndexOf('\n');
var end = t.LastIndexOf("```", StringComparison.Ordinal);
return start >= 0 && end > start ? t[(start + 1)..end].Trim() : t;
}
/// <summary>Keeps a field on its line and inside its column.</summary>
public static string Flatten(string s) => s.Replace('\r', ' ').Replace('\n', ' ').Replace('|', ' ');
}
@@ -54,29 +54,6 @@ public static class Prompts
Reply with JSON only, no prose and no code fence: {"into":"article-slug"} to fold, or {"keep":true} to keep.
""";
/// <summary>Placeholder in <see cref="Plan"/> replaced by <c>MaxSourceCharsPerArticle</c>.</summary>
public const string MaxSourceCharsToken = "{maxSourceChars}";
public const string Plan = """
[plan]
You plan the article set of an encyclopedia about one household: the house and its homelab IT. Its readers are everyone who needs to know things about the house, from the owner to guests.
You receive the wiki's notes, one per line as `slug | title | tags | body length in chars`, and the current plan as JSON if there is one.
Decide which subjects get their own article and which notes feed each article.
- Granularity follows the material, like notability on Wikipedia. A subject gets its own article when the wiki holds enough on it: for example a heating valve controller with 17 automations and a complicated history.
- A subject that was set once and forgotten is not an article of its own. Its notes go into the article of its parent system as a section.
- Every note is used by at least one article, or is deliberately skipped. Skip only meta notes and notes about the wiki's own tooling.
- Keep the existing slugs for existing subjects. Do not rename or renumber them.
- Keep the existing titles for existing subjects unless the subject changed.
- Titles and slugs name nothing concrete in this house: no hostnames, device IDs, addresses or IP addresses. Name the subject by what it is or does, for example "Netzwerkspeicher" rather than the server's hostname.
- List each article's sources most important first. When the sources of an article add up to more than {maxSourceChars} characters of body, split the subject into narrower articles.
- 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. 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"]}]}
""";
/// <summary>The generic-examples rule, shared by <see cref="Write"/> and <see cref="LeakCheck"/>.</summary>
public const string GenericExamples =
"A generic example that explains a principle and names nothing concrete in this house, for example what an entity ID or an IP address looks like, is not a leak and stays public";
@@ -1,11 +1,10 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Wiki.Core.Model;
using Wiki.Core.Ports;
using static Wiki.Application.Encyclopedia.PlanParsing;
namespace Wiki.Application.Encyclopedia;
@@ -13,14 +12,11 @@ namespace Wiki.Application.Encyclopedia;
public sealed record SplitOutcome(PlannedArticle Overview, IReadOnlyList<PlannedArticle> Children);
/// <summary>Splits one oversized article into an overview and narrower children, in one bounded model call.</summary>
public sealed partial class SplitArticleUseCase(ITextModel model, EncyclopediaSettings settings, ILogger<SplitArticleUseCase> log)
public sealed class SplitArticleUseCase(ITextModel model, EncyclopediaSettings settings, ILogger<SplitArticleUseCase> log)
{
private const int MinChildren = 2;
private const int MaxChildren = 6;
[GeneratedRegex("^[a-z0-9]+(-[a-z0-9]+)*$")]
private static partial Regex SlugPattern();
/// <summary>Body chars of the sources that exist in <paramref name="notes"/> exceed the char cap, or the source count exceeds the count cap.</summary>
public static bool NeedsSplit(PlannedArticle a, IReadOnlyDictionary<string, Document> notes, EncyclopediaSettings s) =>
a.Sources.Count > s.MaxSourcesPerArticle
@@ -54,15 +50,7 @@ public sealed partial class SplitArticleUseCase(ITextModel model, EncyclopediaSe
return null;
}
JsonObject? root;
try
{
root = JsonNode.Parse(ArticlePlanJson.StripFence(reply.Text)) as JsonObject;
}
catch (JsonException)
{
root = null;
}
var root = PlanParsing.Object(reply.Text);
if (root is null)
{
log.LogWarning("Split reply for {Slug} is not a JSON object", article.Slug);
@@ -110,8 +98,8 @@ public sealed partial class SplitArticleUseCase(ITextModel model, EncyclopediaSe
return null;
}
var kept = overviewSources.Distinct(StringComparer.Ordinal)
.OrderBy(s => article.Sources.ToList().IndexOf(s)).ToList();
var rank = article.Sources.Select((s, i) => (s, i)).DistinctBy(x => x.s).ToDictionary(x => x.s, x => x.i, StringComparer.Ordinal);
var kept = overviewSources.Distinct(StringComparer.Ordinal).OrderBy(s => rank[s]).ToList();
var rest = article.Sources.Where(s => !input.Contains(s)).ToList();
var overviewAll = kept.Concat(rest).ToList();
if (overviewAll.Count == 0)
@@ -135,9 +123,7 @@ public sealed partial class SplitArticleUseCase(ITextModel model, EncyclopediaSe
log.LogWarning("Split of {Slug}: child slug {Child} is taken or duplicate", article.Slug, slug);
return null;
}
var categoryText = Text(o["category"]);
if (categoryText is null || !categoryText.All(char.IsAsciiLetter)
|| !Enum.TryParse<ArticleCategory>(categoryText, ignoreCase: true, out var category))
if (!TryCategory(Text(o["category"]), out var category))
{
log.LogWarning("Split of {Slug}: child {Child} has an unknown category", article.Slug, slug);
return null;
@@ -156,12 +142,6 @@ public sealed partial class SplitArticleUseCase(ITextModel model, EncyclopediaSe
return new PlannedArticle(slug, titleDe, titleEn, category, scope, sources);
}
private static string? Text(JsonNode? n) =>
n is JsonValue v && v.TryGetValue<string>(out var s) && !string.IsNullOrWhiteSpace(s) ? s.Trim() : null;
private static List<string> Strings(JsonNode? n) =>
n is JsonArray a ? a.Select(Text).OfType<string>().ToList() : [];
private static string UserPrompt(PlannedArticle article, List<string> shown, IReadOnlyDictionary<string, Document> notes)
{
var sb = new StringBuilder();
@@ -177,7 +157,4 @@ public sealed partial class SplitArticleUseCase(ITextModel model, EncyclopediaSe
}
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('|', ' ');
}
@@ -200,7 +200,7 @@ public sealed partial class WriteArticleUseCase(ITextModel model, EncyclopediaSe
{
try
{
if (JsonNode.Parse(ArticlePlanJson.StripFence(text)) is not JsonObject root || root["privatize"] is not JsonArray items) return null;
if (JsonNode.Parse(PlanParsing.StripFence(text)) is not JsonObject root || root["privatize"] is not JsonArray items) return null;
var result = new List<int>();
foreach (var item in items)
{
@@ -219,7 +219,7 @@ public sealed partial class WriteArticleUseCase(ITextModel model, EncyclopediaSe
{
try
{
if (JsonNode.Parse(ArticlePlanJson.StripFence(text)) is not JsonArray items) return null;
if (JsonNode.Parse(PlanParsing.StripFence(text)) is not JsonArray items) return null;
var result = new List<string>();
foreach (var item in items)
{
+2 -2
View File
@@ -28,7 +28,7 @@ public static class ApiV1
}).RequireAuthorization(Auth.Policies.Write);
// Syncs first so the run plans from the current notes. The run outlives a caller that hangs up;
// only shutdown stops it. A run the text model stopped is a failure for the caller.
// only shutdown stops it. A run the text model stopped, or one that ends without a planned article, is a failure for the caller.
v1.MapPost("/encyclopedia/run", async (IServiceProvider services, SyncCoordinator sync, IHostApplicationLifetime lifetime) =>
{
var encyclopedia = services.GetService<EncyclopediaCoordinator>();
@@ -38,7 +38,7 @@ public static class ApiV1
await sync.RunAsync(force: false, ct);
var summary = await encyclopedia.RunAsync(ct);
return summary is null ? Results.Json(new { error = encyclopedia.LastError }, statusCode: StatusCodes.Status502BadGateway)
: summary.StoppedByModel ? Results.Json(summary, statusCode: StatusCodes.Status502BadGateway)
: summary.StoppedByModel || summary.Planned == 0 ? Results.Json(summary, statusCode: StatusCodes.Status502BadGateway)
: Results.Ok(summary);
}).RequireAuthorization(Auth.Policies.Write);
}
@@ -42,7 +42,7 @@
<tr><td>Last run</td><td>@Status.LastEncyclopediaAttemptAt.Value.ToString("u")</td></tr>
@if (Status.LastEncyclopediaRun is { } r)
{
<tr><td>Result</td><td>@r.Planned planned, @r.Written written, @r.Deleted deleted, @r.Rejected rejected@(r.Skipped is null ? "" : $" (skipped: {r.Skipped})")@(r.StoppedByModel ? " (stopped: model unavailable)" : "")</td></tr>
<tr><td>Result</td><td>@r.Planned planned, @r.Assigned assigned, @r.Split split, @r.Folded folded, @r.Written written, @r.Deleted deleted, @r.Rejected rejected@(r.Skipped is null ? "" : $" (skipped: {r.Skipped})")@(r.StoppedByModel ? " (stopped: model unavailable)" : "")</td></tr>
}
@if (Status.LastEncyclopediaError is not null)
{
+14 -1
View File
@@ -84,11 +84,24 @@ public sealed class EncyclopediaOptions
public string AnswerModel { get; set; } = "claude-sonnet";
public string Model { get; set; } = "claude-sonnet-5";
public int ArticleMaxTokens { get; set; } = 16000;
public int PlanMaxTokens { get; set; } = 32000;
public int MaxArticlesPerRun { get; set; } = 10;
public int MaxSourceCharsPerArticle { get; set; } = 300_000;
/// <summary>When non-empty, only these subjects are written.</summary>
public List<string> PilotSlugs { get; set; } = [];
/// <summary>Fallback write cap: the first N planned subjects.</summary>
public int? MaxArticles { get; set; } = null;
public int AssignBatchSize { get; set; } = 20;
/// <summary>Nearby articles per note or article; an assign call sees at most twice this.</summary>
public int CandidateArticles { get; set; } = 10;
public int NeighbourNotes { get; set; } = 30;
public int NeighbourSources { get; set; } = 10;
public int MaxAssignBatchesPerRun { get; set; } = 120;
public int MaxSplitsPerRun { get; set; } = 5;
public int MaxFoldsPerRun { get; set; } = 5;
public int MaxSourcesPerArticle { get; set; } = 120;
public int FoldMinSources { get; set; } = 3;
public int FoldMinChars { get; set; } = 4000;
public int AssignMaxTokens { get; set; } = 8000;
public int SplitMaxTokens { get; set; } = 8000;
public int FoldMaxTokens { get; set; } = 1000;
}
+20 -3
View File
@@ -114,9 +114,26 @@ if (encyclopedia.Enabled)
o.ApiKey = builder.Configuration[$"Wiki:AnswerModels:Models:{encyclopedia.AnswerModel}:ApiKey"] ?? string.Empty;
});
builder.Services.AddSingleton(new Wiki.Application.Encyclopedia.EncyclopediaSettings(
encyclopedia.PlanMaxTokens, encyclopedia.ArticleMaxTokens, encyclopedia.MaxArticlesPerRun,
encyclopedia.MaxSourceCharsPerArticle, encyclopedia.PilotSlugs, encyclopedia.MaxArticles));
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.PlanArticlesUseCase>();
encyclopedia.ArticleMaxTokens, encyclopedia.MaxArticlesPerRun, encyclopedia.MaxSourceCharsPerArticle,
encyclopedia.PilotSlugs, encyclopedia.MaxArticles,
AssignBatchSize: encyclopedia.AssignBatchSize,
CandidateArticles: encyclopedia.CandidateArticles,
NeighbourNotes: encyclopedia.NeighbourNotes,
NeighbourSources: encyclopedia.NeighbourSources,
MaxAssignBatchesPerRun: encyclopedia.MaxAssignBatchesPerRun,
MaxSplitsPerRun: encyclopedia.MaxSplitsPerRun,
MaxFoldsPerRun: encyclopedia.MaxFoldsPerRun,
MaxSourcesPerArticle: encyclopedia.MaxSourcesPerArticle,
FoldMinSources: encyclopedia.FoldMinSources,
FoldMinChars: encyclopedia.FoldMinChars,
AssignMaxTokens: encyclopedia.AssignMaxTokens,
SplitMaxTokens: encyclopedia.SplitMaxTokens,
FoldMaxTokens: encyclopedia.FoldMaxTokens));
// ArticleNeighbours takes the active IEmbeddingModel: the index holds its vectors.
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.ArticleNeighbours>();
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.AssignNotesUseCase>();
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.SplitArticleUseCase>();
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.FoldArticleUseCase>();
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.IArticleWriter, Wiki.Application.Encyclopedia.WriteArticleUseCase>();
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.IEncyclopediaRun, Wiki.Application.Encyclopedia.EncyclopediaReconciler>();
builder.Services.AddSingleton<Wiki.Application.Encyclopedia.EncyclopediaCoordinator>();
@@ -8,9 +8,9 @@ public class ArticleNeighboursTests
{
private const string Model = "m";
private static readonly EncyclopediaSettings Settings = new(8000, 16000, 10, 300_000, [], null);
private static readonly EncyclopediaSettings Settings = new(16000, 10, 300_000, [], null);
private static Document Note(string slug, string hash = "h") => PlanArticlesUseCaseTests.Note(slug, hash: hash);
private static Document Note(string slug, string hash = "h") => TestNotes.Note(slug, hash: hash);
private static PlannedArticle Article(string slug, params string[] sources) =>
new(slug, slug, slug, ArticleCategory.Topic, "scope", sources);
@@ -152,7 +152,7 @@ public class ArticlePlanTests
Assert.Contains("\"category\": \"system\"", json);
Assert.NotNull(read);
Assert.Equal(plan.Articles, read.Articles, new PlannedArticleComparer());
Assert.Equal(["n1", "n2", "meta"], read.Assignments.Keys);
Assert.Equal(["meta", "n1", "n2"], read.Assignments.Keys);
Assert.Equal(["heating"], read.Assignments["n2"]);
Assert.Empty(read.Assignments["meta"]);
Assert.Equal("heating", read.Redirects["old"]);
@@ -162,6 +162,27 @@ public class ArticlePlanTests
public void Json_OldFormat_IsNull() =>
Assert.Null(ArticlePlanJson.Read("""{"articles":[{"slug":"a","category":"topic","sources":["x"],"related":[]}],"coveredNotes":["x"]}"""));
[Fact]
public void Json_Write_MapKeysOrdinal_SameForSameContent()
{
var plan = ArticlePlan.Empty();
plan.AddArticle(Article("a"));
plan.AddArticle(Article("c", "n9"));
plan.AddArticle(Article("d", "n0"));
plan.Assign("n2", ["a"]);
plan.Assign("n1", ["a"]);
plan.Fold("d", "c");
plan.AddArticle(Article("b", "n8"));
plan.Fold("b", "c");
var json = ArticlePlanJson.Write(plan);
var maps = json[json.IndexOf("\"assignments\"", StringComparison.Ordinal)..];
Assert.True(maps.IndexOf("\"n1\"", StringComparison.Ordinal) < maps.IndexOf("\"n2\"", StringComparison.Ordinal));
Assert.True(maps.IndexOf("\"b\": \"c\"", StringComparison.Ordinal) < maps.IndexOf("\"d\": \"c\"", StringComparison.Ordinal));
Assert.Equal(json, ArticlePlanJson.Write(ArticlePlanJson.Read(json)!));
}
[Fact]
public void Json_Malformed_IsNull() => Assert.Null(ArticlePlanJson.Read("not json"));
@@ -6,7 +6,7 @@ namespace Wiki.Application.Tests;
public class AssignNotesUseCaseTests
{
private static readonly EncyclopediaSettings Settings = new(8000, 16000, 10, 300_000, [], null);
private static readonly EncyclopediaSettings Settings = new(16000, 10, 300_000, [], null);
private static AssignNotesUseCase UseCase(FakeTextModel model) =>
new(model, Settings, NullLogger<AssignNotesUseCase>.Instance);
@@ -22,7 +22,7 @@ public class AssignNotesUseCaseTests
}
private static Document[] Batch(params string[] slugs) =>
slugs.Select(s => PlanArticlesUseCaseTests.Note(s)).ToArray();
slugs.Select(s => TestNotes.Note(s)).ToArray();
[Fact]
public async Task Assign_ExistingAndNew()
@@ -130,7 +130,7 @@ public class AssignNotesUseCaseTests
[Fact]
public async Task Assign_FirstParagraph_SkipsHeadingsAndBlankLines()
{
var note = PlanArticlesUseCaseTests.Note("a") with { Body = "# T\n\n \nfirst line\ncontinued\n\nsecond para" };
var note = TestNotes.Note("a") with { Body = "# T\n\n \nfirst line\ncontinued\n\nsecond para" };
var model = new FakeTextModel().Reply("""{"new":[],"notes":[]}""");
await UseCase(model).AssignAsync([note], [], ArticlePlan.Empty(), CancellationToken.None);
@@ -142,7 +142,7 @@ public class AssignNotesUseCaseTests
[Fact]
public async Task Assign_FirstParagraph_CutTo300()
{
var note = PlanArticlesUseCaseTests.Note("a") with { Body = new string('y', 500) };
var note = TestNotes.Note("a") with { Body = new string('y', 500) };
var model = new FakeTextModel().Reply("""{"new":[],"notes":[]}""");
await UseCase(model).AssignAsync([note], [], ArticlePlan.Empty(), CancellationToken.None);
@@ -11,7 +11,7 @@ public class EncyclopediaCoordinatorTests
public Task<ReconcileSummary> RunAsync(CancellationToken ct) => body(ct);
}
private static readonly ReconcileSummary Done = new(3, 2, 0, 0, 0, false, null, "c1");
private static readonly ReconcileSummary Done = new(3, 0, 0, 0, 2, 0, 0, 0, false, null, "c1");
private static EncyclopediaCoordinator Coordinator(Func<CancellationToken, Task<ReconcileSummary>> body) =>
new(new ScriptedRun(body), new FakeTimeProvider(new DateTimeOffset(2026, 10, 24, 4, 0, 0, TimeSpan.Zero)),
@@ -6,6 +6,7 @@ using Wiki.Application.Encyclopedia;
using Wiki.Application.Sync;
using Wiki.Core.Model;
using Wiki.Core.Parsing;
using Wiki.Core.Ports;
namespace Wiki.Application.Tests;
@@ -14,39 +15,56 @@ public class EncyclopediaReconcilerTests
private const string PlanPath = "articles/_plan.json";
private const string StatePath = "articles/_state.json";
private static readonly DateTimeOffset Now = new(2026, 10, 24, 3, 0, 0, TimeSpan.Zero);
private static readonly EncyclopediaSettings Defaults = new(8000, 16000, 10, 300_000, [], null);
private static readonly (string Slug, string[] Sources)[] HeatingAndKitchen =
/// <summary>Fold is off (no article has fewer than zero sources) unless a test turns it on.</summary>
private static readonly EncyclopediaSettings Defaults = new(16000, 10, 300_000, [], null) { FoldMinSources = 0 };
private static readonly (string Slug, string[] Notes)[] HeatingAndKitchen =
[("heating", ["boiler", "radiators"]), ("kitchen", ["kitchen"])];
private readonly FakeStore _store = new();
private readonly FakeCatalog _catalog = new();
private readonly FakeTextModel _planModel = new("plan");
private readonly FakeIndex _index = new();
private readonly SwitchableEmbeddings _embeddings = new(new FakeEmbeddings("m"));
private readonly FakeTextModel _model = new("plan");
private readonly FakeWriter _writer = new();
private readonly FakeTimeProvider _clock = new(Now);
private static string Note(string title, string body = "Body.") => $"---\ntitle: {title}\ntype: wiki\n---\n\n{body}\n";
private static string PlanReply(params (string Slug, string[] Sources)[] articles) =>
"{\"articles\":[" + string.Join(",", articles.Select(a =>
$"{{\"slug\":\"{a.Slug}\",\"titleDe\":\"{a.Slug} de\",\"titleEn\":\"{a.Slug} en\",\"category\":\"topic\"," +
$"\"sources\":[{string.Join(",", a.Sources.Select(s => $"\"{s}\""))}],\"related\":[]}}")) + "]}";
/// <summary>An assign reply creating <paramref name="articles"/>, each fed by its notes.</summary>
private static string AssignReply(params (string Slug, string[] Notes)[] articles) =>
TitledAssignReply(articles.Select(a => (a.Slug, a.Slug + " de", a.Slug + " en", a.Notes)).ToArray());
private static string TitledPlanReply(string titleDe, string titleEn) =>
private static string TitledAssignReply(params (string Slug, string TitleDe, string TitleEn, string[] Notes)[] articles) =>
JsonSerializer.Serialize(new
{
articles = new[] { new { slug = "heating", titleDe, titleEn, category = "system", sources = new[] { "boiler" }, related = Array.Empty<string>() } },
@new = articles.Select(a => new { slug = a.Slug, titleDe = a.TitleDe, titleEn = a.TitleEn, category = "topic", scope = "About " + a.Slug + "." }),
notes = articles.SelectMany(a => a.Notes.Select(n => (Note: n, a.Slug)))
.GroupBy(x => x.Note)
.Select(g => new { note = g.Key, articles = g.Select(x => x.Slug).ToArray() }),
});
/// <summary>An assign reply sending each note to existing articles.</summary>
private static string ExistingReply(params (string Note, string Article)[] notes) =>
JsonSerializer.Serialize(new
{
@new = Array.Empty<object>(),
notes = notes.Select(n => new { note = n.Note, articles = new[] { n.Article } }),
});
private EncyclopediaReconciler Reconciler(EncyclopediaSettings? settings = null, IArticleWriter? writer = null)
{
var s = settings ?? Defaults;
return new(_store, _catalog, new PlanArticlesUseCase(_planModel, s, NullLogger<PlanArticlesUseCase>.Instance),
return new(_store, _catalog, new ArticleNeighbours(_embeddings, _index, s),
new AssignNotesUseCase(_model, s, NullLogger<AssignNotesUseCase>.Instance),
new SplitArticleUseCase(_model, s, NullLogger<SplitArticleUseCase>.Instance),
new FoldArticleUseCase(_model, s, NullLogger<FoldArticleUseCase>.Instance),
writer ?? _writer, s, _clock, NullLogger<EncyclopediaReconciler>.Instance);
}
private Task<SyncSummary> Sync(bool force = true) =>
new SyncUseCase(_store, _catalog, new FakeIndex(), [new FakeEmbeddings("m")], _clock, NullLogger<SyncUseCase>.Instance)
new SyncUseCase(_store, _catalog, _index, [new FakeEmbeddings("m")], _clock, NullLogger<SyncUseCase>.Instance)
.RunAsync(force, CancellationToken.None);
private async Task SeedAsync(params string[] notes)
@@ -56,11 +74,13 @@ public class EncyclopediaReconcilerTests
await Sync();
}
/// <summary>Seeds the default notes, runs once with <see cref="HeatingAndKitchen"/> and syncs the result.</summary>
private static string[] Numbered(int count) => Enumerable.Range(1, count).Select(i => $"note-{i:00}").ToArray();
/// <summary>Seeds the default notes, runs once assigning <see cref="HeatingAndKitchen"/> and syncs the result.</summary>
private async Task FirstRunAsync(EncyclopediaSettings? settings = null)
{
await SeedAsync();
_planModel.Reply(PlanReply(HeatingAndKitchen));
_model.Reply(AssignReply(HeatingAndKitchen));
await Reconciler(settings).RunAsync(CancellationToken.None);
await Sync();
}
@@ -69,23 +89,315 @@ public class EncyclopediaReconcilerTests
private EncyclopediaState State => EncyclopediaStateJson.Read(_store.Files[StatePath]);
private ArticlePlan Plan => ArticlePlanJson.Read(_store.Files[PlanPath])!;
[Fact]
public async Task Reconcile_FirstRun_PlansWritesCommitsOnce()
public async Task Reconcile_FirstRun_AssignsWritesCommitsOnce()
{
await SeedAsync();
_planModel.Reply(PlanReply(HeatingAndKitchen));
_model.Reply(AssignReply(HeatingAndKitchen));
var summary = await Reconciler().RunAsync(CancellationToken.None);
var commit = Assert.Single(_store.Commits);
Assert.Equal("articles: 2 written, 0 deleted, 0 rejected", commit.Message);
Assert.Equal("articles: 3 assigned, 0 split, 0 folded, 2 written, 0 deleted, 0 rejected", commit.Message);
Assert.Equal(
[PlanPath, StatePath, "articles/de/heating.md", "articles/de/kitchen.md", "articles/en/heating.md", "articles/en/kitchen.md"],
commit.Changes.Select(c => c.Path).Order(StringComparer.Ordinal));
Assert.All(commit.Changes, c => Assert.Equal(FileChangeOp.Create, c.Op));
Assert.Equal(new ReconcileSummary(2, 2, 0, 0, 0, false, null, "c2"), summary);
Assert.Equal(new ReconcileSummary(2, 3, 0, 0, 2, 0, 0, 0, false, null, "c2"), summary);
Assert.Equal(AttemptOutcome.Written, State.Subjects["heating"].Outcome);
Assert.Equal(Now, State.Subjects["heating"].At);
Assert.Equal(["boiler", "radiators"], Plan.Find("heating")!.Sources);
Assert.StartsWith("[assign]", Assert.Single(_model.Calls).System);
}
[Fact]
public async Task Run_Bootstrap_LaterBatchesSeeEarlierArticles()
{
var notes = Numbered(40);
await SeedAsync(notes);
_model.Reply(AssignReply(("alpha", notes)));
_model.Reply(AssignReply(("beta", notes)));
var summary = await Reconciler(Defaults with { AssignBatchSize = 20 }).RunAsync(CancellationToken.None);
Assert.Equal(2, _model.Calls.Count);
Assert.DoesNotContain("alpha |", _model.Calls[0].User);
Assert.Contains("alpha |", _model.Calls[1].User);
Assert.Equal(40, summary.Assigned);
Assert.Equal(40, Plan.Assignments.Count);
Assert.Equal(20, Plan.Find("alpha")!.Sources.Count);
Assert.Equal(20, Plan.Find("beta")!.Sources.Count);
}
[Fact]
public async Task Run_OnlyNewNotesAreAssigned()
{
await FirstRunAsync();
_store.Files["wiki/oven.md"] = Note("oven");
await Sync();
_model.Reply(ExistingReply(("oven", "kitchen")));
var summary = await Reconciler().RunAsync(CancellationToken.None);
Assert.Equal(2, _model.Calls.Count);
Assert.Contains("oven |", _model.Calls[1].User);
Assert.DoesNotContain("boiler |", _model.Calls[1].User);
Assert.Equal(1, summary.Assigned);
Assert.Equal(["kitchen", "oven"], Plan.Find("kitchen")!.Sources);
Assert.Equal(["heating", "kitchen", "kitchen"], _writer.Calls);
}
[Fact]
public async Task Run_RemovedNote_DropsSourceAndDeletesEmptyArticle()
{
await FirstRunAsync();
_store.Files.Remove("wiki/kitchen.md");
_store.Files.Remove("wiki/radiators.md");
await Sync();
var summary = await Reconciler().RunAsync(CancellationToken.None);
Assert.Single(_model.Calls);
Assert.Equal(1, summary.Deleted);
Assert.Equal(1, summary.Written);
Assert.Equal(1, summary.Planned);
Assert.Equal(["articles/de/kitchen.md", "articles/en/kitchen.md"],
LastCommit.Where(c => c.Op == FileChangeOp.Delete).Select(c => c.Path).Order(StringComparer.Ordinal));
Assert.DoesNotContain("articles/de/kitchen.md", _store.Files.Keys);
Assert.DoesNotContain("kitchen", State.Subjects.Keys);
Assert.Null(Plan.Find("kitchen"));
Assert.Equal(["boiler"], Plan.Find("heating")!.Sources);
Assert.Equal(["boiler"], Plan.Assignments.Keys);
Assert.Equal("heating", _writer.Calls[^1]);
}
[Fact]
public async Task Run_UnusableBatch_RememberedAndNotRetried()
{
await SeedAsync();
_model.Reply("I would rather not.");
var first = await Reconciler().RunAsync(CancellationToken.None);
await Sync();
var second = await Reconciler().RunAsync(CancellationToken.None);
Assert.Equal(0, first.Planned);
Assert.Single(_model.Calls);
Assert.Single(State.FailedBatches);
Assert.Equal([StatePath], Assert.Single(_store.Commits).Changes.Select(c => c.Path));
Assert.Null(second.Commit);
Assert.Empty(_writer.Calls);
}
[Fact]
public async Task Run_UnusableBatch_RetriedWhenANoteChanges()
{
await SeedAsync();
_model.Reply("I would rather not.");
await Reconciler().RunAsync(CancellationToken.None);
_store.Files["wiki/boiler.md"] = Note("boiler", "New boiler.");
await Sync();
_model.Reply(AssignReply(HeatingAndKitchen));
var summary = await Reconciler().RunAsync(CancellationToken.None);
Assert.Equal(2, _model.Calls.Count);
Assert.Equal(2, summary.Planned);
}
[Fact]
public async Task Run_SplitThenWriteOverviewWithChildrenAsLinks()
{
await SeedAsync("boiler", "radiators", "pump", "valve", "kitchen");
_model.Reply(AssignReply(("heating", ["boiler", "radiators", "pump", "valve"]), ("kitchen", ["kitchen"])));
_model.Reply(JsonSerializer.Serialize(new
{
overview = new { scope = "Heating in general.", sources = new[] { "boiler" } },
children = new[]
{
new { slug = "heating-radiators", titleDe = "Heizkörper", titleEn = "Radiators", category = "device", scope = "The radiators.", sources = new[] { "radiators" } },
new { slug = "heating-pump", titleDe = "Pumpe", titleEn = "Pump", category = "device", scope = "The pump.", sources = new[] { "pump" } },
},
}));
// no nearby articles: the overview's link targets are its children alone
var summary = await Reconciler(Defaults with { MaxSourcesPerArticle = 3, CandidateArticles = 0 }).RunAsync(CancellationToken.None);
Assert.Equal(1, summary.Split);
Assert.StartsWith("[split]", _model.Calls[1].System);
Assert.Equal(["boiler", "valve"], Plan.Find("heating")!.Sources);
Assert.Equal("Heating in general.", Plan.Find("heating")!.Scope);
Assert.Equal(["heating-pump"], Plan.Assignments["pump"]);
Assert.Equal(4, summary.Planned);
Assert.Equal(["heating-pump", "heating-radiators"], _writer.Links["heating"].Order(StringComparer.Ordinal));
Assert.Empty(_writer.Links["kitchen"]);
Assert.Equal(["heating", "heating-pump", "heating-radiators", "kitchen"], _writer.Calls.Order(StringComparer.Ordinal));
}
[Fact]
public async Task Run_SplitChildrenAreLinksOnlyInTheSplittingRun()
{
await SeedAsync("boiler", "radiators", "pump", "valve");
_model.Reply(AssignReply(("heating", ["boiler", "radiators", "pump", "valve"])));
_model.Reply(JsonSerializer.Serialize(new
{
overview = new { scope = "Heating in general.", sources = new[] { "boiler" } },
children = new[]
{
new { slug = "heating-radiators", titleDe = "Heizkörper", titleEn = "Radiators", category = "device", scope = "The radiators.", sources = new[] { "radiators" } },
new { slug = "heating-pump", titleDe = "Pumpe", titleEn = "Pump", category = "device", scope = "The pump.", sources = new[] { "pump" } },
},
}));
var settings = Defaults with { MaxSourcesPerArticle = 3, CandidateArticles = 0 };
await Reconciler(settings).RunAsync(CancellationToken.None);
await Sync();
_store.Files["wiki/boiler.md"] = Note("boiler", "New boiler.");
await Sync();
await Reconciler(settings).RunAsync(CancellationToken.None);
Assert.Equal("heating", _writer.Calls[^1]);
Assert.Empty(_writer.Links["heating"]);
}
[Fact]
public async Task Run_FoldDeletesFilesAndAddsRedirect()
{
await FirstRunAsync();
_model.Reply("""{"into":"heating"}""");
var summary = await Reconciler(Defaults with { FoldMinSources = 2 }).RunAsync(CancellationToken.None);
Assert.Equal(1, summary.Folded);
Assert.Equal(1, summary.Deleted);
Assert.StartsWith("[fold]", _model.Calls[^1].System);
Assert.Contains("kitchen |", _model.Calls[^1].User);
Assert.Equal("heating", Plan.Redirects["kitchen"]);
Assert.Equal(["boiler", "radiators", "kitchen"], Plan.Find("heating")!.Sources);
Assert.Equal(["heating"], Plan.Assignments["kitchen"]);
Assert.DoesNotContain("articles/de/kitchen.md", _store.Files.Keys);
Assert.DoesNotContain("articles/en/kitchen.md", _store.Files.Keys);
Assert.DoesNotContain("kitchen", State.Subjects.Keys);
Assert.Equal("heating", _writer.Calls[^1]);
}
[Fact]
public async Task Run_KeptArticle_NotAskedAgain()
{
var settings = Defaults with { FoldMinSources = 2 };
await FirstRunAsync();
_model.Reply("""{"keep":true}""");
await Reconciler(settings).RunAsync(CancellationToken.None);
await Sync();
var calls = _model.Calls.Count;
var summary = await Reconciler(settings).RunAsync(CancellationToken.None);
Assert.Equal(calls, _model.Calls.Count);
Assert.Equal(0, summary.Folded);
Assert.Equal(["kitchen"], State.KeptArticles.Keys);
Assert.Null(summary.Commit);
}
[Fact]
public async Task Run_EmbeddingsDown_StillWritesStale()
{
await FirstRunAsync();
_store.Files["wiki/boiler.md"] = Note("boiler", "New boiler.");
_store.Files["wiki/garage.md"] = Note("garage");
await Sync();
_embeddings.Down = true;
var summary = await Reconciler().RunAsync(CancellationToken.None);
Assert.Single(_model.Calls);
Assert.False(summary.StoppedByModel);
Assert.Equal(0, summary.Assigned);
Assert.Equal(1, summary.Written);
Assert.Equal("heating", _writer.Calls[^1]);
Assert.Empty(_writer.Links["heating"]);
Assert.NotNull(summary.Commit);
Assert.False(Plan.IsAssigned("garage"));
}
[Fact]
public async Task Run_StepCapsRespected()
{
var notes = Numbered(40);
await SeedAsync(notes);
_model.Reply(AssignReply(("alpha", notes)));
var summary = await Reconciler(Defaults with { AssignBatchSize = 20, MaxAssignBatchesPerRun = 1 }).RunAsync(CancellationToken.None);
Assert.Single(_model.Calls);
Assert.Equal(20, summary.Assigned);
Assert.Equal(20, Plan.Assignments.Count);
}
[Fact]
public async Task Run_SplitAndFoldCapsRespected()
{
await SeedAsync("a1", "a2", "b1", "b2", "c1", "c2");
_model.Reply(AssignReply(("a", ["a1", "a2"]), ("b", ["b1", "b2"]), ("c", ["c1", "c2"])));
_model.Reply("{}");
_model.Reply("""{"keep":true}""");
await Reconciler(Defaults with { MaxSourcesPerArticle = 1, MaxSplitsPerRun = 1, FoldMinSources = 3, MaxFoldsPerRun = 1 })
.RunAsync(CancellationToken.None);
Assert.Equal(["[assign]", "[split]", "[fold]"], _model.Calls.Select(c => c.System.Split('\n')[0]));
}
[Fact]
public async Task Run_ModelUnavailable_CommitsStaged()
{
var notes = Numbered(40);
await SeedAsync(notes);
_model.Reply(AssignReply(("alpha", notes)));
_model.Throw(new ModelUnavailableException("credit exhausted"));
var summary = await Reconciler(Defaults with { AssignBatchSize = 20 }).RunAsync(CancellationToken.None);
Assert.True(summary.StoppedByModel);
Assert.Equal(20, summary.Assigned);
Assert.Empty(_writer.Calls);
Assert.Equal([PlanPath], Assert.Single(_store.Commits).Changes.Select(c => c.Path));
Assert.Equal(20, Plan.Assignments.Count);
}
[Fact]
public async Task Run_WriteLinkTargetsAreBounded()
{
var notes = Numbered(15);
await SeedAsync(notes);
_model.Reply(AssignReply(notes.Select(n => ("a-" + n, new[] { n })).ToArray()));
var settings = Defaults with { CandidateArticles = 3, MaxArticlesPerRun = 20 };
await Reconciler(settings).RunAsync(CancellationToken.None);
Assert.Equal(15, _writer.Calls.Count);
Assert.All(_writer.Links.Values, l => Assert.InRange(l.Count, 0, 3));
Assert.Contains(_writer.Links.Values, l => l.Count == 3);
}
[Fact]
public async Task Run_SourcesBeyondCharCap_LeftOutAndNotStale()
{
_store.Files["wiki/a.md"] = Note("a", new string('x', 60));
_store.Files["wiki/b.md"] = Note("b", new string('y', 60));
await Sync();
_model.Reply(AssignReply(("heating", ["a", "b"])));
var settings = Defaults with { MaxSourceCharsPerArticle = 100, MaxSourcesPerArticle = 120, MaxSplitsPerRun = 0 };
await Reconciler(settings).RunAsync(CancellationToken.None);
await Sync();
var summary = await Reconciler(settings).RunAsync(CancellationToken.None);
Assert.Equal(["a"], _writer.Sources["heating"]);
Assert.Null(summary.Commit);
Assert.Single(_writer.Calls);
}
[Fact]
@@ -97,7 +409,7 @@ public class EncyclopediaReconcilerTests
var summary = await Reconciler().RunAsync(CancellationToken.None);
Assert.Equal("catalog-behind", summary.Skipped);
Assert.Empty(_planModel.Calls);
Assert.Empty(_model.Calls);
Assert.Empty(_writer.Calls);
Assert.Empty(_store.Commits);
}
@@ -106,7 +418,7 @@ public class EncyclopediaReconcilerTests
public async Task Reconcile_AfterOwnCommit_IsNoop()
{
await SeedAsync();
_planModel.Reply(PlanReply(HeatingAndKitchen));
_model.Reply(AssignReply(HeatingAndKitchen));
await Reconciler().RunAsync(CancellationToken.None);
var sync = await Sync(force: false);
Assert.False(sync.Skipped);
@@ -116,7 +428,7 @@ public class EncyclopediaReconcilerTests
Assert.Null(summary.Skipped);
Assert.Null(summary.Commit);
Assert.Single(_store.Commits);
Assert.Single(_planModel.Calls);
Assert.Single(_model.Calls);
Assert.Equal(2, _writer.Calls.Count);
}
@@ -131,7 +443,7 @@ public class EncyclopediaReconcilerTests
Assert.Equal(1, summary.Written);
Assert.Equal(["heating", "kitchen", "heating"], _writer.Calls);
Assert.Single(_planModel.Calls);
Assert.Single(_model.Calls);
Assert.Equal(["articles/_state.json", "articles/de/heating.md", "articles/en/heating.md"],
LastCommit.Select(c => c.Path).Order(StringComparer.Ordinal));
Assert.All(LastCommit, c => Assert.Equal(FileChangeOp.Update, c.Op));
@@ -150,10 +462,10 @@ public class EncyclopediaReconcilerTests
}
[Fact]
public async Task Reconcile_ModelUnavailable_CommitsWhatIsDone()
public async Task Reconcile_WriterModelUnavailable_CommitsWhatIsDone()
{
await SeedAsync();
_planModel.Reply(PlanReply(HeatingAndKitchen));
_model.Reply(AssignReply(HeatingAndKitchen));
_writer.Throws["kitchen"] = new ModelUnavailableException("credit exhausted");
var summary = await Reconciler().RunAsync(CancellationToken.None);
@@ -166,13 +478,13 @@ public class EncyclopediaReconcilerTests
}
[Fact]
public async Task Reconcile_PlanModelUnavailable_StopsKeepsPlan()
public async Task Reconcile_AssignModelUnavailable_StopsKeepsPlan()
{
await FirstRunAsync();
var plan = _store.Files[PlanPath];
_store.Files["wiki/garage.md"] = Note("garage");
await Sync();
_planModel.Throw(new ModelUnavailableException("credit exhausted"));
_model.Throw(new ModelUnavailableException("credit exhausted"));
var summary = await Reconciler().RunAsync(CancellationToken.None);
@@ -180,7 +492,6 @@ public class EncyclopediaReconcilerTests
Assert.Single(_store.Commits);
Assert.Equal(plan, _store.Files[PlanPath]);
Assert.Equal(2, _writer.Calls.Count);
Assert.Null(State.FailedPlan);
}
[Fact]
@@ -196,7 +507,7 @@ public class EncyclopediaReconcilerTests
var summary = await Reconciler().RunAsync(CancellationToken.None);
Assert.Equal(1, summary.Rejected);
Assert.Equal("articles: 0 written, 0 deleted, 1 rejected", _store.Commits[^1].Message);
Assert.Equal("articles: 0 assigned, 0 split, 0 folded, 0 written, 0 deleted, 1 rejected", _store.Commits[^1].Message);
Assert.Equal([StatePath], LastCommit.Select(c => c.Path));
Assert.Equal(de, _store.Files["articles/de/heating.md"]);
Assert.Equal(en, _store.Files["articles/en/heating.md"]);
@@ -247,7 +558,7 @@ public class EncyclopediaReconcilerTests
public async Task Reconcile_Http400_RememberedAsRejected()
{
await SeedAsync();
_planModel.Reply(PlanReply(("heating", ["boiler"])));
_model.Reply(AssignReply(("heating", ["boiler", "radiators", "kitchen"])));
var writeModel = new FakeTextModel("write")
.Throw(new HttpRequestException("invalid request", null, HttpStatusCode.BadRequest));
var writer = new WriteArticleUseCase(writeModel, Defaults, _clock);
@@ -267,7 +578,7 @@ public class EncyclopediaReconcilerTests
public async Task Reconcile_TransientHttpError_NotRemembered()
{
await SeedAsync();
_planModel.Reply(PlanReply(("heating", ["boiler"])));
_model.Reply(AssignReply(("heating", ["boiler", "radiators", "kitchen"])));
_writer.Throws["heating"] = new HttpRequestException("bad gateway", null, HttpStatusCode.BadGateway);
var first = await Reconciler().RunAsync(CancellationToken.None);
@@ -284,7 +595,7 @@ public class EncyclopediaReconcilerTests
public async Task Reconcile_StalledStream_CommitsOthersNotRemembered()
{
await SeedAsync();
_planModel.Reply(PlanReply(HeatingAndKitchen));
_model.Reply(AssignReply(HeatingAndKitchen));
_writer.Throws["heating"] = new HttpRequestException(HttpRequestError.ResponseEnded, "stream stalled", new TimeoutException());
var summary = await Reconciler().RunAsync(CancellationToken.None);
@@ -295,73 +606,19 @@ public class EncyclopediaReconcilerTests
}
[Fact]
public async Task Reconcile_PlanFailedSameNotes_DoesNotReplan()
{
await SeedAsync();
_planModel.Reply("I would rather not.");
var first = await Reconciler().RunAsync(CancellationToken.None);
await Sync();
var second = await Reconciler().RunAsync(CancellationToken.None);
Assert.Equal("no-plan", first.Skipped);
Assert.Equal("no-plan", second.Skipped);
Assert.Single(_planModel.Calls);
Assert.Single(_store.Commits);
Assert.NotNull(State.FailedPlan);
}
[Fact]
public async Task Reconcile_NoPlan_WritesNothing()
{
var old = ArticleFile.Build(new Article("de", "old", "Alt", ArticleCategory.Topic, ArticleVisibility.Public,
[new ArticleSource("boiler", "h")], new DateOnly(2026, 1, 1), "m", "Alt.\n"));
_store.Files["articles/de/old.md"] = old;
await SeedAsync();
_planModel.Reply("I would rather not.");
var summary = await Reconciler().RunAsync(CancellationToken.None);
Assert.Equal("no-plan", summary.Skipped);
Assert.Equal([StatePath], Assert.Single(_store.Commits).Changes.Select(c => c.Path));
Assert.Equal(old, _store.Files["articles/de/old.md"]);
Assert.Empty(_writer.Calls);
}
[Fact]
public async Task Reconcile_SubjectDropped_DeletesBothLanguages()
{
await FirstRunAsync();
_store.Files["wiki/garage.md"] = Note("garage");
await Sync();
_planModel.Reply(PlanReply(("heating", ["boiler", "radiators"]), ("garage", ["garage"])));
var summary = await Reconciler().RunAsync(CancellationToken.None);
Assert.Equal(1, summary.Deleted);
Assert.Equal(1, summary.Written);
Assert.Equal("articles: 1 written, 1 deleted, 0 rejected", _store.Commits[^1].Message);
Assert.Equal(["articles/de/kitchen.md", "articles/en/kitchen.md"],
LastCommit.Where(c => c.Op == FileChangeOp.Delete).Select(c => c.Path).Order(StringComparer.Ordinal));
Assert.DoesNotContain("articles/de/kitchen.md", _store.Files.Keys);
Assert.DoesNotContain("kitchen", State.Subjects.Keys);
Assert.Equal(["heating", "kitchen", "garage"], _writer.Calls);
}
[Fact]
public async Task Reconcile_ArticleNotInAnyPlan_NotDeleted()
public async Task Reconcile_ArticleNotInPlan_NotDeleted()
{
var legacy = ArticleFile.Build(new Article("de", "legacy", "Alt", ArticleCategory.Topic, ArticleVisibility.Public,
[new ArticleSource("boiler", "h")], new DateOnly(2026, 1, 1), "m", "Alt.\n"));
_store.Files["articles/de/legacy.md"] = legacy;
await FirstRunAsync();
_store.Files["wiki/garage.md"] = Note("garage");
_store.Files.Remove("wiki/kitchen.md");
await Sync();
_planModel.Reply(PlanReply([.. HeatingAndKitchen, ("garage", ["garage"])]));
await Reconciler().RunAsync(CancellationToken.None);
Assert.All(_store.Commits.SelectMany(c => c.Changes), c => Assert.NotEqual(FileChangeOp.Delete, c.Op));
Assert.Equal(["articles/de/kitchen.md", "articles/en/kitchen.md"],
LastCommit.Where(c => c.Op == FileChangeOp.Delete).Select(c => c.Path).Order(StringComparer.Ordinal));
Assert.Equal(legacy, _store.Files["articles/de/legacy.md"]);
}
@@ -369,7 +626,7 @@ public class EncyclopediaReconcilerTests
public async Task Reconcile_PilotCapShrinks_DeletesNothing()
{
await SeedAsync("boiler", "radiators", "kitchen", "garage");
_planModel.Reply(PlanReply([.. HeatingAndKitchen, ("garage", ["garage"])]));
_model.Reply(AssignReply([.. HeatingAndKitchen, ("garage", ["garage"])]));
await Reconciler().RunAsync(CancellationToken.None);
await Sync();
Assert.Equal(6, _store.Files.Keys.Count(p => p.StartsWith("articles/d", StringComparison.Ordinal)
@@ -386,7 +643,7 @@ public class EncyclopediaReconcilerTests
public async Task Reconcile_PilotSlugs_WritesOnlyListed()
{
await SeedAsync();
_planModel.Reply(PlanReply(HeatingAndKitchen));
_model.Reply(AssignReply(HeatingAndKitchen));
await Reconciler(Defaults with { PilotSlugs = ["kitchen"] }).RunAsync(CancellationToken.None);
@@ -398,7 +655,7 @@ public class EncyclopediaReconcilerTests
[Fact]
public void Due_MaxArticles_KeepsOnlyFirstOfPlan()
{
var notes = new[] { PlanArticlesUseCaseTests.Note("a"), PlanArticlesUseCaseTests.Note("b") };
var notes = new[] { TestNotes.Note("a"), TestNotes.Note("b") };
var a = new PlannedArticle("a", "A", "A", ArticleCategory.Topic, "", ["a"]);
var b = new PlannedArticle("b", "B", "B", ArticleCategory.Topic, "", ["b"]);
var plan = ArticlePlan.Empty();
@@ -415,7 +672,7 @@ public class EncyclopediaReconcilerTests
{
await SeedAsync();
_store.Files["articles/de/heating.md"] = "written by someone else";
_planModel.Reply(PlanReply(HeatingAndKitchen));
_model.Reply(AssignReply(HeatingAndKitchen));
var summary = await Reconciler().RunAsync(CancellationToken.None);
@@ -430,7 +687,7 @@ public class EncyclopediaReconcilerTests
public async Task Reconcile_AmbiguousTitles_IsNoopOnSecondRun(string titleDe, string titleEn)
{
await SeedAsync();
_planModel.Reply(TitledPlanReply(titleDe, titleEn));
_model.Reply(TitledAssignReply(("heating", titleDe, titleEn, ["boiler", "radiators", "kitchen"])));
await Reconciler().RunAsync(CancellationToken.None);
await Sync();
@@ -445,7 +702,7 @@ public class EncyclopediaReconcilerTests
public async Task Reconcile_WriterTimeout_CommitsStagedThenThrows()
{
await SeedAsync();
_planModel.Reply(PlanReply(HeatingAndKitchen));
_model.Reply(AssignReply(HeatingAndKitchen));
_writer.Throws["kitchen"] = new TaskCanceledException("HttpClient timeout");
await Assert.ThrowsAsync<TaskCanceledException>(() => Reconciler().RunAsync(CancellationToken.None));
@@ -459,7 +716,7 @@ public class EncyclopediaReconcilerTests
public async Task Reconcile_CallerCancelled_NoCommit()
{
await SeedAsync();
_planModel.Reply(PlanReply(HeatingAndKitchen));
_model.Reply(AssignReply(HeatingAndKitchen));
using var cts = new CancellationTokenSource();
_writer.Before = slug => { if (slug == "kitchen") cts.Cancel(); };
_writer.Throws["kitchen"] = new OperationCanceledException(cts.Token);
@@ -472,16 +729,16 @@ public class EncyclopediaReconcilerTests
[Fact]
public void Stale_TitleOnlyChange_IsStale()
{
var notes = new[] { PlanArticlesUseCaseTests.Note("a", hash: "h1") };
var notes = new[] { TestNotes.Note("a", hash: "h1") };
var planned = new PlannedArticle("a", "Neu", "New", ArticleCategory.Topic, "", ["a"]);
Article Existing(string lang, string title) => new(lang, "a", title, ArticleCategory.Topic, ArticleVisibility.Public,
[new ArticleSource("a", "h1")], new DateOnly(2026, 10, 1), "m", "x\n");
var plan = ArticlePlan.Empty();
plan.AddArticle(planned);
Assert.Empty(EncyclopediaReconciler.Stale(plan, notes, [Existing("de", "Neu"), Existing("en", "New")]));
Assert.Equal([planned], EncyclopediaReconciler.Stale(plan, notes, [Existing("de", "Alt"), Existing("en", "New")]));
Assert.Equal([planned], EncyclopediaReconciler.Stale(plan, notes, [Existing("de", "Neu"), Existing("en", "Old")]));
Assert.Empty(EncyclopediaReconciler.Stale(plan, notes, [Existing("de", "Neu"), Existing("en", "New")], Defaults));
Assert.Equal([planned], EncyclopediaReconciler.Stale(plan, notes, [Existing("de", "Alt"), Existing("en", "New")], Defaults));
Assert.Equal([planned], EncyclopediaReconciler.Stale(plan, notes, [Existing("de", "Neu"), Existing("en", "Old")], Defaults));
}
[Fact]
@@ -490,7 +747,7 @@ public class EncyclopediaReconcilerTests
await FirstRunAsync();
_store.Files["wiki/attic.md"] = Note("attic");
await Sync();
_planModel.Reply(PlanReply([.. HeatingAndKitchen, ("attic", ["attic"])]));
_model.Reply(AssignReply(("attic", ["attic"])));
await Reconciler().RunAsync(CancellationToken.None);
await Sync();
var commits = _store.Commits.Count;
@@ -502,10 +759,28 @@ public class EncyclopediaReconcilerTests
Assert.Equal(["attic", "heating", "kitchen"], State.Subjects.Keys);
}
/// <summary>Embeddings that fail like an unreachable service while <see cref="Down"/>.</summary>
private sealed class SwitchableEmbeddings(IEmbeddingModel inner) : IEmbeddingModel
{
public bool Down { get; set; }
public string ModelId => inner.ModelId;
public int Dimension => inner.Dimension;
public string Name => inner.Name;
public Task<IReadOnlyList<float[]>> EmbedAsync(IReadOnlyList<string> texts, CancellationToken ct) =>
Down ? Task.FromException<IReadOnlyList<float[]>>(new HttpRequestException("connection refused")) : inner.EmbedAsync(texts, ct);
public Task<AdapterHealth> CheckAsync(CancellationToken ct) => inner.CheckAsync(ct);
}
/// <summary>Writes "Text {slug}" in both languages from the given sources; throws the scripted exception for a slug.</summary>
private sealed class FakeWriter : IArticleWriter
{
public List<string> Calls { get; } = [];
/// <summary>Link target slugs of the last write of each article.</summary>
public Dictionary<string, List<string>> Links { get; } = new();
/// <summary>Source slugs of the last write of each article.</summary>
public Dictionary<string, List<string>> Sources { get; } = new();
public Dictionary<string, Exception> Throws { get; } = new();
public Action<string>? Before { get; set; }
@@ -513,6 +788,8 @@ public class EncyclopediaReconcilerTests
IReadOnlyList<PlannedArticle> linkTargets, CancellationToken ct)
{
Calls.Add(planned.Slug);
Links[planned.Slug] = linkTargets.Select(a => a.Slug).ToList();
Sources[planned.Slug] = sources.Select(d => d.Slug).ToList();
Before?.Invoke(planned.Slug);
if (Throws.TryGetValue(planned.Slug, out var e)) return Task.FromException<WrittenArticle>(e);
var de = new Article("de", planned.Slug, planned.TitleDe, planned.Category, ArticleVisibility.Public,
@@ -16,14 +16,12 @@ public class EncyclopediaStateTests
["heating"] = new("abc", AttemptOutcome.Written, At),
["sauna"] = new("def", AttemptOutcome.Rejected, At),
},
new PlanAttempt("zzz", At),
new Dictionary<string, DateTimeOffset>(),
new Dictionary<string, string>());
var read = EncyclopediaStateJson.Read(EncyclopediaStateJson.Write(state));
Assert.Equal(state.Subjects, read.Subjects);
Assert.Equal(state.FailedPlan, read.FailedPlan);
}
[Fact]
@@ -31,7 +29,6 @@ public class EncyclopediaStateTests
{
var state = new EncyclopediaState(
new Dictionary<string, SubjectAttempt>(),
null,
new Dictionary<string, DateTimeOffset> { ["fp1"] = At },
new Dictionary<string, string> { ["heating"] = "fp2" });
@@ -42,21 +39,22 @@ public class EncyclopediaStateTests
}
[Fact]
public void Read_OldJsonWithoutNewFields_ReadsEmptyBatchesAndKept()
public void Read_OldJsonWithFailedPlan_IgnoresIt()
{
var read = EncyclopediaStateJson.Read("""{"subjects":{},"failedPlan":{"noteSetFingerprint":"x","at":"2026-10-24T03:00:00+00:00"}}""");
Assert.NotNull(read.FailedPlan);
Assert.Empty(read.Subjects);
Assert.Empty(read.FailedBatches);
Assert.Empty(read.KeptArticles);
Assert.DoesNotContain("failedPlan", EncyclopediaStateJson.Write(read));
}
[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")]);
var ab = EncyclopediaState.BatchFingerprint([TestNotes.Note("a", hash: "1"), TestNotes.Note("b", hash: "2")]);
var ba = EncyclopediaState.BatchFingerprint([TestNotes.Note("b", hash: "2"), TestNotes.Note("a", hash: "1")]);
var changed = EncyclopediaState.BatchFingerprint([TestNotes.Note("a", hash: "1"), TestNotes.Note("b", hash: "3")]);
var fewer = EncyclopediaState.BatchFingerprint([TestNotes.Note("a", hash: "1")]);
Assert.Equal(ab, ba);
Assert.NotEqual(ab, changed);
Assert.NotEqual(ab, fewer);
@@ -68,7 +66,6 @@ public class EncyclopediaStateTests
{
var read = EncyclopediaStateJson.Read("{{{ nope");
Assert.Empty(read.Subjects);
Assert.Null(read.FailedPlan);
}
[Fact]
@@ -89,25 +86,15 @@ public class EncyclopediaStateTests
public void SubjectFingerprint_ChangesWithSourceHashOrTitle()
{
var planned = new PlannedArticle("heating", "Heizung", "Heating", ArticleCategory.System, "", ["a", "b"]);
var notes = new[] { PlanArticlesUseCaseTests.Note("a", hash: "1"), PlanArticlesUseCaseTests.Note("b", hash: "2") };
var notes = new[] { TestNotes.Note("a", hash: "1"), TestNotes.Note("b", hash: "2") };
var baseline = EncyclopediaState.SubjectFingerprint(planned, notes);
Assert.Equal(baseline, EncyclopediaState.SubjectFingerprint(planned, notes));
Assert.Matches("^[0-9a-f]{64}$", baseline);
var changedHash = new[] { notes[0], PlanArticlesUseCaseTests.Note("b", hash: "3") };
var changedHash = new[] { notes[0], TestNotes.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]
public void NoteSetFingerprint_IgnoresOrder_ChangesWithSlugs()
{
var ab = EncyclopediaState.NoteSetFingerprint([PlanArticlesUseCaseTests.Note("a"), PlanArticlesUseCaseTests.Note("b")]);
var ba = EncyclopediaState.NoteSetFingerprint([PlanArticlesUseCaseTests.Note("b"), PlanArticlesUseCaseTests.Note("a")]);
var abc = EncyclopediaState.NoteSetFingerprint([PlanArticlesUseCaseTests.Note("a"), PlanArticlesUseCaseTests.Note("b"), PlanArticlesUseCaseTests.Note("c")]);
Assert.Equal(ab, ba);
Assert.NotEqual(ab, abc);
}
}
+8
View File
@@ -6,6 +6,14 @@ using Wiki.Core.Ports;
namespace Wiki.Application.Tests;
public static class TestNotes
{
/// <summary>A wiki note with a body of <paramref name="bodyLength"/> chars, tagged heating.</summary>
public static Document Note(string slug, int bodyLength = 100, string hash = "h", string title = "T") =>
new(DocumentKind.Wiki, slug, $"wiki/{slug}.md", hash, title, null, ["heating"], [], [], [], null,
new Dictionary<string, FrontmatterValue>(), new string('x', bodyLength), 0);
}
/// <summary>In-memory ports for use-case tests. Content hash = SHA-1 of the text, like a git blob.</summary>
public sealed class FakeStore : INoteStore
{
@@ -6,7 +6,7 @@ namespace Wiki.Application.Tests;
public class FoldArticleUseCaseTests
{
private static readonly EncyclopediaSettings Settings = new(8000, 16000, 10, 300_000, [], null) { CandidateArticles = 2 };
private static readonly EncyclopediaSettings Settings = new(16000, 10, 300_000, [], null) { CandidateArticles = 2 };
private static FoldArticleUseCase UseCase(FakeTextModel model) =>
new(model, Settings, NullLogger<FoldArticleUseCase>.Instance);
@@ -15,7 +15,7 @@ public class FoldArticleUseCaseTests
new(slug, "Titel " + slug, "Title " + slug, ArticleCategory.System, "Scope " + slug, sources);
private static Dictionary<string, Document> Notes(params string[] slugs) =>
slugs.ToDictionary(s => s, s => PlanArticlesUseCaseTests.Note(s));
slugs.ToDictionary(s => s, s => TestNotes.Note(s));
[Fact]
public async Task Fold_Into()
@@ -30,6 +30,23 @@ public class FoldArticleUseCaseTests
Assert.StartsWith("[fold]", model.Calls[0].System);
}
[Fact]
public async Task Fold_ListsAtMostFoldMinSources()
{
var model = new FakeTextModel().Reply("""{"keep":true}""");
var thin = Article("valve", "a", "b", "c", "d");
var notes = new Dictionary<string, Document>
{
["a"] = TestNotes.Note("a", title: "Na"), ["b"] = TestNotes.Note("b", title: "Nb"),
["c"] = TestNotes.Note("c", title: "Nc"), ["d"] = TestNotes.Note("d", title: "Nd"),
};
await UseCase(model).FoldAsync(thin, notes, [Article("heating", "h")], CancellationToken.None);
Assert.Contains("Nc", model.Calls[0].User);
Assert.DoesNotContain("Nd", model.Calls[0].User);
}
[Fact]
public async Task Fold_Keep_IsNull()
{
@@ -109,13 +126,13 @@ public class FoldArticleUseCaseTests
[Fact]
public void IsThin_Thresholds()
{
var s = new EncyclopediaSettings(8000, 16000, 10, 300_000, [], null);
var s = new EncyclopediaSettings(16000, 10, 300_000, [], null);
var notes = new Dictionary<string, Document>
{
["a"] = PlanArticlesUseCaseTests.Note("a", 1000),
["b"] = PlanArticlesUseCaseTests.Note("b", 1000),
["c"] = PlanArticlesUseCaseTests.Note("c", 1000),
["big"] = PlanArticlesUseCaseTests.Note("big", 4000),
["a"] = TestNotes.Note("a", 1000),
["b"] = TestNotes.Note("b", 1000),
["c"] = TestNotes.Note("c", 1000),
["big"] = TestNotes.Note("big", 4000),
};
Assert.True(FoldArticleUseCase.IsThin(Article("x", "a", "b"), notes, s));
@@ -1,215 +0,0 @@
using System.Net;
using Microsoft.Extensions.Logging.Abstractions;
using Wiki.Application.Encyclopedia;
using Wiki.Core.Model;
namespace Wiki.Application.Tests;
public class PlanArticlesUseCaseTests
{
private static readonly EncyclopediaSettings Settings = new(8000, 16000, 10, 300_000, [], null);
internal static Document Note(string slug, int bodyLength = 100, string hash = "h", string title = "T") =>
new(DocumentKind.Wiki, slug, $"wiki/{slug}.md", hash, title, null, ["heating"], [], [], [], null,
new Dictionary<string, FrontmatterValue>(), new string('x', bodyLength), 0);
private static PlanArticlesUseCase UseCase(FakeTextModel model, EncyclopediaSettings? settings = null) =>
new(model, settings ?? Settings, NullLogger<PlanArticlesUseCase>.Instance);
private static ArticlePlan PlanFrom(PlannedArticle article, params string[] notes)
{
var plan = ArticlePlan.Empty();
plan.AddArticle(article);
foreach (var n in notes) plan.Assign(n, article.Sources.Contains(n) ? [article.Slug] : []);
return plan;
}
private static ArticlePlan PlanOf(params string[] covered) =>
PlanFrom(new PlannedArticle("heating", "Heizung", "Heating", ArticleCategory.System, "", covered), covered);
[Fact]
public void NeedsReplan_NullPlan_True() =>
Assert.True(PlanArticlesUseCase.NeedsReplan(null, null, [Note("a")]));
[Fact]
public void NeedsReplan_SameSlugs_False() =>
Assert.False(PlanArticlesUseCase.NeedsReplan(PlanOf("b", "a"), null, [Note("a"), Note("b")]));
[Fact]
public void NeedsReplan_NoteAdded_True() =>
Assert.True(PlanArticlesUseCase.NeedsReplan(PlanOf("a"), null, [Note("a"), Note("b")]));
[Fact]
public void NeedsReplan_FailedForSameNoteSet_False()
{
var notes = new[] { Note("a"), Note("b") };
var failed = new PlanAttempt(EncyclopediaState.NoteSetFingerprint(notes), DateTimeOffset.UnixEpoch);
Assert.False(PlanArticlesUseCase.NeedsReplan(null, failed, notes));
}
[Fact]
public void NeedsReplan_FailedForOtherNoteSet_True()
{
var failed = new PlanAttempt(EncyclopediaState.NoteSetFingerprint([Note("a")]), DateTimeOffset.UnixEpoch);
Assert.True(PlanArticlesUseCase.NeedsReplan(null, failed, [Note("a"), Note("b")]));
}
[Fact]
public async Task Plan_ParsesAndValidates()
{
var model = new FakeTextModel().Reply("""
```json
{"articles":[
{"slug":"heating","titleDe":"Heizung","titleEn":"Heating","category":"system","sources":["a","ghost","b"]},
{"slug":"Bad Slug","titleDe":"x","titleEn":"x","category":"topic","sources":["a"]},
{"slug":"heating","titleDe":"dup","titleEn":"dup","category":"topic","sources":["a"]},
{"slug":"empty","titleDe":"x","titleEn":"x","category":"topic","sources":["ghost"]}
]}
```
""");
var plan = await UseCase(model).PlanAsync(null, [Note("a"), Note("b")], CancellationToken.None);
Assert.NotNull(plan);
var article = Assert.Single(plan.Articles);
Assert.Equal("heating", article.Slug);
Assert.Equal(ArticleCategory.System, article.Category);
Assert.Equal(["a", "b"], article.Sources);
Assert.Equal(["a", "b"], plan.Assignments.Keys.Order());
}
[Fact]
public async Task Plan_ModelGarbage_KeepsExistingPlan()
{
var model = new FakeTextModel().Reply("Sure! Here is");
Assert.Null(await UseCase(model).PlanAsync(PlanOf("a"), [Note("a")], CancellationToken.None));
}
[Fact]
public async Task Plan_Truncated_KeepsExistingPlan()
{
var json = """{"articles":[{"slug":"heating","titleDe":"H","titleEn":"H","category":"system","sources":["a"]}]}""";
var model = new FakeTextModel().Reply(json, truncated: true);
Assert.Null(await UseCase(model).PlanAsync(null, [Note("a")], CancellationToken.None));
}
[Fact]
public async Task Plan_Http400_ReturnsNull()
{
var model = new FakeTextModel().Throw(new HttpRequestException("bad", null, HttpStatusCode.BadRequest));
Assert.Null(await UseCase(model).PlanAsync(null, [Note("a")], CancellationToken.None));
}
[Fact]
public async Task Plan_ModelUnavailable_Propagates()
{
var model = new FakeTextModel().Throw(new ModelUnavailableException("quota"));
await Assert.ThrowsAsync<ModelUnavailableException>(() => UseCase(model).PlanAsync(null, [Note("a")], CancellationToken.None));
}
[Fact]
public async Task Plan_Http500_Propagates()
{
var model = new FakeTextModel().Throw(new HttpRequestException("boom", null, HttpStatusCode.InternalServerError));
await Assert.ThrowsAsync<HttpRequestException>(() => UseCase(model).PlanAsync(null, [Note("a")], CancellationToken.None));
}
[Fact]
public async Task Plan_SourcesOverCharCap_DropsLowestRanked()
{
var model = new FakeTextModel().Reply("""{"articles":[{"slug":"big","titleDe":"G","titleEn":"B","category":"topic","sources":["a","b","c"]}]}""");
var notes = new[] { Note("a", 200_000), Note("b", 200_000), Note("c", 200_000) };
var plan = await UseCase(model).PlanAsync(null, notes, CancellationToken.None);
Assert.Equal(["a"], Assert.Single(plan!.Articles).Sources);
}
[Fact]
public async Task Plan_PromptContainsExistingSlugs()
{
var model = new FakeTextModel().Reply("nope");
var current = PlanFrom(new PlannedArticle("heating-valves", "H", "H", ArticleCategory.Device, "", ["a"]), ["a"]);
await UseCase(model).PlanAsync(current, [Note("a", 42, title: "Valve notes")], CancellationToken.None);
var call = Assert.Single(model.Calls);
Assert.Contains("heating-valves", call.User);
Assert.Contains("a | Valve notes | heating | 42", call.User);
Assert.StartsWith("[plan]", call.System);
Assert.Equal(Settings.PlanMaxTokens, call.MaxTokens);
}
[Fact]
public async Task Plan_PromptForbidsConcreteTitles_AndKeepsTitles()
{
var model = new FakeTextModel().Reply("nope");
await UseCase(model).PlanAsync(null, [Note("a")], CancellationToken.None);
var system = Assert.Single(model.Calls).System;
Assert.Contains("Titles and slugs name nothing concrete in this house: no hostnames, device IDs, addresses or IP addresses.", system);
Assert.Contains("Keep the existing titles for existing subjects unless the subject changed.", system);
}
[Fact]
public async Task Plan_PromptAsksForCompactJson_AndOmitsCoveredNotes()
{
var model = new FakeTextModel().Reply("nope");
var current = PlanFrom(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","scope":"","sources":["a"]}]""", 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"]}],"assignments":{}}""");
Assert.Equal(["b"], plan!.Articles.Select(a => a.Slug));
}
[Fact]
public void ArticlePlanJson_Malformed_ReadsNull()
{
Assert.Null(ArticlePlanJson.Read("not json"));
Assert.Null(ArticlePlanJson.Read("""{"foo":1}"""));
}
[Fact]
public void ArticlePlanJson_RoundTrip()
{
var plan = PlanOf("a", "b");
var read = ArticlePlanJson.Read(ArticlePlanJson.Write(plan));
Assert.NotNull(read);
Assert.Equal(plan.Articles[0], read.Articles[0],new PlannedArticleComparer());
Assert.Equal(plan.Assignments.Keys, read.Assignments.Keys);
}
private sealed class PlannedArticleComparer : IEqualityComparer<PlannedArticle>
{
public bool Equals(PlannedArticle? x, PlannedArticle? y) =>
x!.Slug == y!.Slug && x.TitleDe == y.TitleDe && x.TitleEn == y.TitleEn && x.Category == y.Category
&& x.Scope == y.Scope && x.Sources.SequenceEqual(y.Sources);
public int GetHashCode(PlannedArticle obj) => obj.Slug.GetHashCode();
}
}
@@ -6,7 +6,7 @@ namespace Wiki.Application.Tests;
public class SplitArticleUseCaseTests
{
private static readonly EncyclopediaSettings Settings = new(8000, 16000, 10, 300_000, [], null) { MaxSourcesPerArticle = 4 };
private static readonly EncyclopediaSettings Settings = new(16000, 10, 300_000, [], null) { MaxSourcesPerArticle = 4 };
private static SplitArticleUseCase UseCase(FakeTextModel model) =>
new(model, Settings, NullLogger<SplitArticleUseCase>.Instance);
@@ -15,7 +15,7 @@ public class SplitArticleUseCaseTests
new(slug, "Titel " + slug, "Title " + slug, ArticleCategory.System, "Scope " + slug, sources);
private static Dictionary<string, Document> Notes(params string[] slugs) =>
slugs.ToDictionary(s => s, s => PlanArticlesUseCaseTests.Note(s));
slugs.ToDictionary(s => s, s => TestNotes.Note(s));
private static ArticlePlan PlanWith(params PlannedArticle[] articles)
{
@@ -193,7 +193,7 @@ public class SplitArticleUseCaseTests
{
var article = Article("heating", "a", "b", "c", "d");
var notes = Notes("a", "b", "c", "d");
notes["a"] = PlanArticlesUseCaseTests.Note("a", title: "one | two\nthree");
notes["a"] = TestNotes.Note("a", title: "one | two\nthree");
var model = new FakeTextModel().Reply(Valid);
await UseCase(model).SplitAsync(article, notes, PlanWith(article), CancellationToken.None);
@@ -204,13 +204,13 @@ public class SplitArticleUseCaseTests
[Fact]
public void NeedsSplit_ByCharsAndByCount()
{
var s = new EncyclopediaSettings(8000, 16000, 10, 1000, [], null) { MaxSourcesPerArticle = 3 };
var s = new EncyclopediaSettings(16000, 10, 1000, [], null) { MaxSourcesPerArticle = 3 };
var notes = new Dictionary<string, Document>
{
["a"] = PlanArticlesUseCaseTests.Note("a", 600),
["b"] = PlanArticlesUseCaseTests.Note("b", 600),
["c"] = PlanArticlesUseCaseTests.Note("c", 100),
["d"] = PlanArticlesUseCaseTests.Note("d", 1),
["a"] = TestNotes.Note("a", 600),
["b"] = TestNotes.Note("b", 600),
["c"] = TestNotes.Note("c", 100),
["d"] = TestNotes.Note("d", 1),
};
Assert.True(SplitArticleUseCase.NeedsSplit(Article("x", "a", "b"), notes, s));
@@ -8,7 +8,7 @@ namespace Wiki.Application.Tests;
public class WriteArticleUseCaseTests
{
private static readonly EncyclopediaSettings Settings = new(8000, 16000, 10, 300_000, [], null);
private static readonly EncyclopediaSettings Settings = new(16000, 10, 300_000, [], null);
private static readonly DateTimeOffset Now = new(2026, 10, 24, 3, 0, 0, TimeSpan.Zero);
private static readonly PlannedArticle Planned =
@@ -18,12 +18,13 @@ public sealed class EncyclopediaEndpointTests
public List<string?> CatalogCommits { get; } = [];
public Exception? Failure { get; set; }
public bool StoppedByModel { get; set; }
public int Planned { get; set; } = 3;
public async Task<ReconcileSummary> RunAsync(CancellationToken ct)
{
CatalogCommits.Add((await catalog.GetStateAsync(ct)).Commit);
if (Failure is not null) throw Failure;
return new ReconcileSummary(3, 0, 0, 0, 0, StoppedByModel, null, null);
return new ReconcileSummary(Planned, 0, 0, 0, 0, 0, 0, 0, StoppedByModel, null, null);
}
}
@@ -135,4 +136,18 @@ public sealed class EncyclopediaEndpointTests
Assert.Equal(HttpStatusCode.BadGateway, r.StatusCode);
Assert.True((await r.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("stoppedByModel").GetBoolean());
}
[Fact]
public async Task Run_EmptyPlan_Is502()
{
var (host, _, run) = Enabled();
using var _h = host;
var client = host.CreateClient();
run().Planned = 0;
var r = await client.SendAsync(RunRequest());
Assert.Equal(HttpStatusCode.BadGateway, r.StatusCode);
Assert.Equal(0, (await r.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("planned").GetInt32());
}
}